diff --git a/README.md b/README.md index 8c360997..7e6832fa 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,24 @@ Use `bsk --help` or `bsk --help` for command options. Always stop your In WorkBuddy/CodeBuddy, or hosts that reap command children, the agent should reuse an existing daemon or run `bsk daemon start --foreground` in a managed background task, then verify it from a separate tool call. Follow the [host setup guide](docs/sandboxed-agents.md) for shared `BSK_HOME`, `BSK_AUTO_START=0`, and the independent-terminal fallback when the host cannot keep a task alive. +On Windows, install a per-user logon launcher in the Startup folder with: + +```powershell +bsk daemon install-autostart +``` + +The launcher uses `daemon start --foreground`, so it is owned by Explorer and +survives the short-lived command that installed it. The generated VBScript is +kept pure ASCII, including when the `bsk.exe` path contains non-ASCII +characters. Remove it with `bsk daemon uninstall-autostart`; both commands are +safe to repeat and refuse to overwrite or remove an unrelated file with the +same name. + +The local daemon exits after its idle timeout (10 minutes by default) once no +browser is connected. An open Edge connection keeps it active; closing the +browser starts the idle countdown, after which the daemon must be launched +again (the Startup launcher will run at the next logon). + ## DeepSeek Harness plugin The [DSH plugin](packages/dsh-plugin-browserskill/README.md) adds native `browser_*` tools, browser task previews, and screenshot results to the DeepSeek Harness Web UI. It uses the same `bsk` CLI and extension and includes its own BrowserSkill skill. diff --git a/README.zh-CN.md b/README.zh-CN.md index 6d973fb1..1cab3e5c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,6 +151,16 @@ bsk session stop 在 WorkBuddy/CodeBuddy 或会回收命令子进程的宿主中,Agent 应先复用已有 daemon;需要启动时,在宿主管理的后台任务中运行 `bsk daemon start --foreground`,并在另一条工具调用中验证连接。[宿主配置指南](docs/sandboxed-agents.md)说明了如何共用 `BSK_HOME`、设置 `BSK_AUTO_START=0`,以及宿主无法维持任务时的独立终端兜底方式。 +在 Windows 上,可以把 daemon 安装为当前用户登录时启动的任务: + +```powershell +bsk daemon install-autostart +``` + +生成的启动文件使用 `daemon start --foreground`,由 Explorer 启动,因此不会随着执行安装命令的短命进程结束。生成的 VBScript 始终是纯 ASCII;即使 `bsk.exe` 路径包含非 ASCII 字符也能正常工作。使用 `bsk daemon uninstall-autostart` 删除;两个命令都支持重复执行,并且不会覆盖或删除同名的非 BrowserSkill 文件。 + +本地 daemon 在没有浏览器连接后开始计算空闲时间,默认约 10 分钟后退出。Edge 保持连接时 daemon 应持续运行;关闭浏览器后才会开始倒计时,退出后需要再次启动 daemon(启动文件会在下一次登录时运行)。 + ## DeepSeek Harness 插件 [DSH 插件](packages/dsh-plugin-browserskill/README.md) 在 DeepSeek Harness Web UI 中提供原生 `browser_*` 工具、浏览器任务预览和截图结果。它使用同一套 `bsk` CLI 与扩展,并自带 BrowserSkill skill。 diff --git a/crates/bsk-cli/src/cli/daemon.rs b/crates/bsk-cli/src/cli/daemon.rs index f171a39a..5f09713c 100644 --- a/crates/bsk-cli/src/cli/daemon.rs +++ b/crates/bsk-cli/src/cli/daemon.rs @@ -24,6 +24,14 @@ pub enum DaemonCmd { /// Stop then start the daemon. Restart(StartArgs), + /// Install a per-user Windows logon launcher for the foreground daemon. + #[command(name = "install-autostart")] + InstallAutostart, + + /// Remove the per-user Windows logon launcher installed by bsk. + #[command(name = "uninstall-autostart")] + UninstallAutostart, + /// Generate a one-use remote browser pairing link. Pair, @@ -182,6 +190,8 @@ pub fn dispatch(cmd: DaemonCmd) -> anyhow::Result<()> { daemon::start::run_stop().map_err(|e| e.context("restart failed during stop phase"))?; daemon::start::run_start(args) } + DaemonCmd::InstallAutostart => crate::cli::daemon_autostart::install(), + DaemonCmd::UninstallAutostart => crate::cli::daemon_autostart::uninstall(), DaemonCmd::Pair => { println!("{}", authorization_store()?.pair()?); Ok(()) diff --git a/crates/bsk-cli/src/cli/daemon_autostart.rs b/crates/bsk-cli/src/cli/daemon_autostart.rs new file mode 100644 index 00000000..c1d45802 --- /dev/null +++ b/crates/bsk-cli/src/cli/daemon_autostart.rs @@ -0,0 +1,256 @@ +//! Per-user Windows logon autostart for the foreground daemon. +//! +//! The launcher is deliberately a tiny, pure-ASCII VBScript in the user's +//! Startup folder. VBScript is read using the user's legacy code page on +//! some Windows installations, so non-ASCII executable paths are represented +//! with `ChrW` expressions instead of being written as source text. + +#[cfg(any(windows, test))] +use std::io::Write; +#[cfg(any(windows, test))] +use std::path::Path; + +#[cfg(windows)] +use std::path::PathBuf; + +use anyhow::{Result, bail}; + +#[cfg(any(windows, test))] +use anyhow::Context; + +#[cfg(any(windows, test))] +const AUTOSTART_FILE_NAME: &str = "bsk-daemon-autostart.vbs"; +#[cfg(any(windows, test))] +const MANAGED_MARKER: &str = "' browser-skill managed autostart v1"; + +#[cfg(any(windows, test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallOutcome { + Installed, + Updated, + AlreadyInstalled, +} + +#[cfg(any(windows, test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UninstallOutcome { + Removed, + AlreadyAbsent, +} + +/// Install the launcher for the current user. +#[cfg(windows)] +pub fn install() -> Result<()> { + let startup = startup_dir()?; + let executable = std::env::current_exe().context("locate bsk executable")?; + let destination = startup.join(AUTOSTART_FILE_NAME); + match install_at(&startup, &executable)? { + InstallOutcome::Installed => { + println!("installed daemon autostart: {}", destination.display()) + } + InstallOutcome::Updated => println!("updated daemon autostart: {}", destination.display()), + InstallOutcome::AlreadyInstalled => { + println!( + "daemon autostart already installed: {}", + destination.display() + ) + } + } + Ok(()) +} + +#[cfg(not(windows))] +pub fn install() -> Result<()> { + bail!("daemon autostart is currently supported on Windows only") +} + +/// Remove the launcher installed by [`install`]. +#[cfg(windows)] +pub fn uninstall() -> Result<()> { + let startup = startup_dir()?; + let destination = startup.join(AUTOSTART_FILE_NAME); + match uninstall_at(&startup)? { + UninstallOutcome::Removed => { + println!("removed daemon autostart: {}", destination.display()) + } + UninstallOutcome::AlreadyAbsent => { + println!("daemon autostart already absent: {}", destination.display()) + } + } + Ok(()) +} + +#[cfg(not(windows))] +pub fn uninstall() -> Result<()> { + bail!("daemon autostart is currently supported on Windows only") +} + +#[cfg(windows)] +fn startup_dir() -> Result { + dirs::data_dir() + .map(|data| data.join("Microsoft\\Windows\\Start Menu\\Programs\\Startup")) + .context("locate the per-user Windows Startup folder") +} + +#[cfg(any(windows, test))] +fn install_at(startup: &Path, executable: &Path) -> Result { + std::fs::create_dir_all(startup) + .with_context(|| format!("create Startup folder {}", startup.display()))?; + let destination = startup.join(AUTOSTART_FILE_NAME); + let script = render_script(executable); + let had_existing = destination.exists(); + + if had_existing { + let existing = std::fs::read(&destination) + .with_context(|| format!("read existing autostart file {}", destination.display()))?; + if !existing.starts_with(MANAGED_MARKER.as_bytes()) { + bail!( + "refusing to overwrite unmanaged autostart file {}", + destination.display() + ); + } + if existing == script.as_bytes() { + return Ok(InstallOutcome::AlreadyInstalled); + } + } + + // Write beside the destination first so an interrupted render never leaves + // a partial VBScript. Windows rename cannot replace an existing file, so a + // managed destination is removed immediately before the final rename. + let mut temp = tempfile::Builder::new() + .prefix("bsk-daemon-autostart-") + .suffix(".tmp") + .tempfile_in(startup) + .with_context(|| format!("create autostart temporary file in {}", startup.display()))?; + temp.write_all(script.as_bytes()) + .with_context(|| format!("write autostart temporary file in {}", startup.display()))?; + temp.as_file() + .sync_all() + .with_context(|| format!("flush autostart temporary file in {}", startup.display()))?; + if destination.exists() { + std::fs::remove_file(&destination) + .with_context(|| format!("replace managed autostart file {}", destination.display()))?; + } + temp.persist(&destination) + .map_err(|error| error.error) + .with_context(|| format!("install autostart file {}", destination.display()))?; + + Ok(if had_existing { + InstallOutcome::Updated + } else { + InstallOutcome::Installed + }) +} + +#[cfg(any(windows, test))] +fn uninstall_at(startup: &Path) -> Result { + let destination = startup.join(AUTOSTART_FILE_NAME); + if !destination.exists() { + return Ok(UninstallOutcome::AlreadyAbsent); + } + let existing = std::fs::read(&destination) + .with_context(|| format!("read autostart file {}", destination.display()))?; + if !existing.starts_with(MANAGED_MARKER.as_bytes()) { + bail!( + "refusing to remove unmanaged autostart file {}", + destination.display() + ); + } + std::fs::remove_file(&destination) + .with_context(|| format!("remove autostart file {}", destination.display()))?; + Ok(UninstallOutcome::Removed) +} + +#[cfg(any(windows, test))] +fn render_script(executable: &Path) -> String { + let executable = executable.to_string_lossy(); + format!( + "{MANAGED_MARKER}\r\nOption Explicit\r\nDim sh\r\nSet sh = CreateObject(\"WScript.Shell\")\r\nsh.Run ChrW(34) & {} & ChrW(34) & \" daemon start --foreground\", 0, False\r\n", + vbs_string_expression(&executable) + ) +} + +#[cfg(any(windows, test))] +fn vbs_string_expression(value: &str) -> String { + let mut terms = Vec::new(); + let mut ascii = String::new(); + let flush_ascii = |terms: &mut Vec, ascii: &mut String| { + if !ascii.is_empty() { + terms.push(format!("\"{ascii}\"")); + ascii.clear(); + } + }; + + for unit in value.encode_utf16() { + if (0x20..=0x7e).contains(&unit) && unit != b'"' as u16 { + ascii.push(char::from_u32(unit as u32).expect("ASCII UTF-16 unit")); + } else { + flush_ascii(&mut terms, &mut ascii); + terms.push(format!("ChrW({unit})")); + } + } + flush_ascii(&mut terms, &mut ascii); + if terms.is_empty() { + "\"\"".to_string() + } else { + terms.join(" & ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn script_is_ascii_and_starts_foreground_daemon() { + let script = render_script(Path::new(r"C:\Program Files\BrowserSkill\bsk.exe")); + assert!(script.is_ascii()); + assert!(script.contains(MANAGED_MARKER)); + assert!(script.contains("daemon start --foreground")); + assert!(script.contains("WScript.Shell")); + assert!(script.contains("ChrW(34)")); + } + + #[test] + fn unicode_executable_path_is_encoded_without_non_ascii_source() { + let path = format!( + r"C:\Users\{}{}\{}{}\bsk.exe", + '\u{7528}', '\u{6237}', '\u{684c}', '\u{9762}' + ); + let script = render_script(Path::new(&path)); + assert!(script.is_ascii()); + assert!(script.contains("ChrW(29992)")); + assert!(script.contains("ChrW(25143)")); + } + + #[test] + fn install_and_uninstall_are_idempotent_and_protect_unmanaged_files() { + let temp = TempDir::new().unwrap(); + let executable = temp.path().join("bsk.exe"); + assert_eq!( + install_at(temp.path(), &executable).unwrap(), + InstallOutcome::Installed + ); + assert_eq!( + install_at(temp.path(), &executable).unwrap(), + InstallOutcome::AlreadyInstalled + ); + let replacement = temp.path().join("other-bsk.exe"); + assert_eq!( + install_at(temp.path(), &replacement).unwrap(), + InstallOutcome::Updated + ); + assert_eq!( + uninstall_at(temp.path()).unwrap(), + UninstallOutcome::Removed + ); + assert_eq!( + uninstall_at(temp.path()).unwrap(), + UninstallOutcome::AlreadyAbsent + ); + std::fs::write(temp.path().join(AUTOSTART_FILE_NAME), "user launcher").unwrap(); + assert!(install_at(temp.path(), &executable).is_err()); + assert!(uninstall_at(temp.path()).is_err()); + } +} diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 32afe05d..ffcf9fdc 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -8,6 +8,7 @@ pub mod browsers; pub mod business_rpc; pub mod console; pub mod daemon; +mod daemon_autostart; pub mod debug; pub mod dialogs; pub mod doctor; diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 59773de5..01797a94 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -166,6 +166,18 @@ fn parses_daemon_stop_and_restart() { assert!(args.foreground); } +#[test] +fn parses_daemon_autostart_commands() { + assert!(matches!( + parse(&["bsk", "daemon", "install-autostart"]).command, + Command::Daemon(DaemonCmd::InstallAutostart) + )); + assert!(matches!( + parse(&["bsk", "daemon", "uninstall-autostart"]).command, + Command::Daemon(DaemonCmd::UninstallAutostart) + )); +} + #[test] fn parses_top_level_status_and_doctor() { let cli = parse(&["bsk", "status"]);