Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ codex-threadripper install-service
- `bucket switch <provider>`:把全部历史切到指定 provider 可见桶;首行有 padding 时只做原地 patch
- `bucket prepare`:给 rollout 首行补足 JSON 空白 padding,让后续 provider 桶切换保持快速
- `watch`:持续监听 `config.toml` 的变化,同时定时收敛新增的线程记录
- `print-service-config`:打印当前平台的后台服务配置内容(launchd plist / systemd unit / Windows 启动脚本)
- `print-service-config`:打印当前平台的后台服务配置内容(launchd plist / systemd unit / Windows 隐藏 VBS 启动器)
- `install-service`:把后台服务装到系统里并立即启动
- `uninstall-service`:停止并卸载后台服务

Expand All @@ -131,7 +131,7 @@ codex-threadripper install-service
- `status`、`sync`、`watch` 在任何能访问 Codex SQLite 状态库和 rollout JSONL 的环境下都能用
- macOS 后台服务使用 `launchd`
- Linux 后台服务使用 `systemd --user`(没有 systemd 时会退回到独立的后台进程)
- Windows 后台服务使用启动文件夹里的批处理脚本
- Windows 后台服务使用启动文件夹里的隐藏 VBS 启动器,登录后不会弹出控制台窗口
- 发布的二进制覆盖 Apple Silicon macOS、Intel macOS、Linux x64、Linux ARM64、Windows x64
- CLI 界面同时支持简体中文和英文,根据系统语言自动切换

Expand Down Expand Up @@ -221,7 +221,7 @@ codex-threadripper install-service
- `bucket switch <provider>`: move all history into the requested provider visibility bucket; prepared first lines are patched in place
- `bucket prepare`: reserve JSON whitespace padding in rollout first lines so future provider bucket switches stay fast
- `watch`: keep watching `config.toml` for provider changes and periodically reconcile any newly added thread rows
- `print-service-config`: print the platform-specific service config (launchd plist, systemd unit, or Windows startup script) without installing it
- `print-service-config`: print the platform-specific service config (launchd plist, systemd unit, or Windows hidden VBS launcher) without installing it
- `install-service`: write the service config and start the service
- `uninstall-service`: stop and remove the service

Expand All @@ -246,7 +246,7 @@ These legacy command names still work:
- `status`, `sync`, and `watch` work anywhere you can access Codex's SQLite state DB and rollout JSONL files
- macOS uses `launchd`
- Linux uses `systemd --user`, falling back to a detached process when systemd isn't available
- Windows uses a startup folder script
- Windows uses a hidden VBS launcher in the Startup folder, so no console window appears on login
- Prebuilt binaries are available for Apple Silicon macOS, Intel macOS, Linux x64, Linux ARM64, and Windows x64
- The CLI detects your system language and switches between Simplified Chinese and English automatically

Expand Down
66 changes: 57 additions & 9 deletions src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ pub fn current_service_status() -> Result<ServiceStatus> {
let manager = current_manager();
let codex_home = current_codex_home()?;
let config_path = service_config_path(manager)?;
let installed = config_path.exists();
let installed = match manager {
ServiceManager::WindowsStartup => {
config_path.exists() || legacy_windows_startup_cmd_path()?.exists()
}
_ => config_path.exists(),
};
let running = service_running(manager, &codex_home)?;
Ok(ServiceStatus {
manager,
Expand Down Expand Up @@ -123,7 +128,7 @@ pub fn render_service_config(
ServiceManager::SystemdUser => {
build_systemd_bundle(exe_path, codex_home, provider_override, poll_interval)
}
ServiceManager::WindowsStartup => Ok(build_windows_startup_script(
ServiceManager::WindowsStartup => Ok(build_windows_startup_vbs(
exe_path,
codex_home,
provider_override,
Expand Down Expand Up @@ -297,8 +302,13 @@ fn install_windows_startup(
})?;
std::fs::create_dir_all(config_dir)?;

let script =
build_windows_startup_script(exe_path, codex_home, provider_override, poll_interval);
let legacy_cmd_path = legacy_windows_startup_cmd_path()?;
if legacy_cmd_path.exists() {
std::fs::remove_file(&legacy_cmd_path)
.with_context(|| format!("failed to remove {}", legacy_cmd_path.display()))?;
}

let script = build_windows_startup_vbs(exe_path, codex_home, provider_override, poll_interval);
std::fs::write(&config_path, script)
.with_context(|| format!("failed to write {}", config_path.display()))?;

Expand All @@ -316,6 +326,13 @@ fn uninstall_windows_startup(config_path: &Path) -> Result<()> {
std::fs::remove_file(config_path)
.with_context(|| format!("failed to remove {}", config_path.display()))?;
}

let legacy_cmd_path = legacy_windows_startup_cmd_path()?;
if legacy_cmd_path.exists() {
std::fs::remove_file(&legacy_cmd_path)
.with_context(|| format!("failed to remove {}", legacy_cmd_path.display()))?;
}

Ok(())
}

Expand All @@ -341,11 +358,15 @@ fn service_config_path(manager: ServiceManager) -> Result<PathBuf> {
.join(".config/systemd/user")
.join(format!("{SERVICE_LABEL}.service"))),
ServiceManager::WindowsStartup => {
Ok(windows_startup_dir()?.join(format!("{SERVICE_LABEL}.cmd")))
Ok(windows_startup_dir()?.join(format!("{SERVICE_LABEL}.vbs")))
}
}
}

fn legacy_windows_startup_cmd_path() -> Result<PathBuf> {
Ok(windows_startup_dir()?.join(format!("{SERVICE_LABEL}.cmd")))
}

fn linux_runner_script_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".local/share/codex-threadripper/run-watch.sh"))
}
Expand Down Expand Up @@ -393,21 +414,26 @@ fn build_linux_runner_script(
)
}

fn build_windows_startup_script(
fn build_windows_startup_vbs(
exe_path: &Path,
codex_home: &Path,
provider_override: Option<&str>,
poll_interval: Duration,
) -> String {
format!(
"@echo off\r\nstart \"\" /B {}\r\n",
let command = format!(
"cmd.exe /c {}",
watch_command_line(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wrap /c payload in outer quotes for cmd.exe

The new VBS launcher builds cmd.exe /c with an already-quoted executable path, but without outer quotes around the full /c payload. For common installs where the binary path contains spaces (for example under C:\Program Files), cmd's documented /c quote parsing will strip the leading quote pair when the payload contains multiple quoted segments, which breaks command resolution and prevents the Startup entry from launching watch at login. Please wrap the entire command string passed to /c (the usual ""..."" pattern) or invoke the executable without this extra cmd.exe /c layer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你说得对。当前实现把已经带引号的可执行路径直接拼到了 cmd.exe /c 后面,没有再给整个 /c payload 做外层包装;在 Program Files 这类带空格路径下,这样确实可能被 cmd 误解析。

这里我已经改成直接让 VBS 调用已经正确加引号的可执行路径和参数,去掉了 cmd.exe /c 这一层,这样可以彻底绕开 cmd 的引号规则。

exe_path,
codex_home,
provider_override,
poll_interval,
ShellFlavor::Cmd
)
),
);

format!(
"Set shell = CreateObject(\"WScript.Shell\")\r\nshell.Run {}, 0, False\r\n",
vbs_quote(&command)
)
}

Expand Down Expand Up @@ -980,6 +1006,10 @@ fn windows_quote(input: &str) -> String {
format!("\"{escaped}\"")
}

fn vbs_quote(input: &str) -> String {
format!("\"{}\"", input.replace('"', "\"\""))
}

fn xml_escape(input: &str) -> String {
input
.replace('&', "&amp;")
Expand Down Expand Up @@ -1019,7 +1049,10 @@ enum ProcessStatus {

#[cfg(test)]
mod tests {
use super::build_windows_startup_vbs;
use super::windows_quote;
use std::path::Path;
use std::time::Duration;

#[test]
fn windows_quote_escapes_shell_metacharacters() {
Expand All @@ -1033,4 +1066,19 @@ mod tests {
);
assert_eq!(windows_quote(r"C:\tmp\a&b\x.exe"), r#""C:\tmp\a^&b\x.exe""#);
}

#[test]
fn windows_startup_vbs_hides_console_window() {
let script = build_windows_startup_vbs(
Path::new(r"C:\Program Files\codex-threadripper\codex-threadripper.exe"),
Path::new(r"C:\Users\admin\.codex"),
Some("my"),
Duration::from_millis(1500),
);

assert!(script.contains(r#"CreateObject("WScript.Shell")"#));
assert!(script.contains("shell.Run"));
assert!(script.contains(", 0, False"));
assert!(script.contains("watch --poll-interval-ms 1500"));
}
}