Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ Use `bsk --help` or `bsk <command> --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.
Expand Down
10 changes: 10 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ bsk session stop <id>

在 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。
Expand Down
10 changes: 10 additions & 0 deletions crates/bsk-cli/src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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(())
Expand Down
256 changes: 256 additions & 0 deletions crates/bsk-cli/src/cli/daemon_autostart.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<InstallOutcome> {
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<UninstallOutcome> {
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<String>, 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());
}
}
1 change: 1 addition & 0 deletions crates/bsk-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions crates/bsk-cli/tests/cli_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down