From c4e5d17a9003a592a0d84d8f81577b88afe864c3 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 22:34:58 +0900 Subject: [PATCH 1/2] fix(bsk): start the Windows daemon from a headless task when the host forbids breakaway An agent host on Windows runs its commands inside a Job Object that forbids breakaway. bsk 0.3.2 then refuses to detach its daemon: `bsk status --json` exits 2 with "cannot start an independent Windows daemon; the host may prohibit Job Object breakaway" and asks for `bsk daemon start --foreground` in a persistent host task. startDaemonWithCli surfaced that as "bsk status exited with 2", so connectBrowserSkill could not start the daemon at all on those hosts. On that refusal, startDaemonWithCli now registers a per-user scheduled task named bsk-daemon- and runs it; Task Scheduler starts the action outside the caller's job, and discovery then reads daemon.json as before. The action runs under conhost.exe --headless: a task that starts a console program directly opens it in the default terminal, and Windows Terminal shows that window whatever -WindowStyle Hidden says. --- package.json | 4 +- skills/omowright/references/browserskill.md | 6 +- src/bsk/daemon-info.js | 62 +++++++++++++++------ test/bsk-daemon-start.test.mjs | 31 +++++++++++ 4 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 test/bsk-daemon-start.test.mjs diff --git a/package.json b/package.json index 1c850b0..77622e6 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ ".": "./src/index.js" }, "scripts": { - "test": "bun test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs", - "test:node": "node --test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs", + "test": "bun test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", + "test:node": "node --test test/branding.test.mjs test/hygiene.test.mjs test/llm.test.mjs test/integration.test.mjs test/interaction.test.mjs test/captcha.test.mjs test/captcha.integration.test.mjs test/chrome.test.mjs test/cookies.test.mjs test/pipe.test.mjs test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs test/events.test.mjs test/agent-tabs.test.mjs test/events.integration.test.mjs test/bridge-transport.test.mjs test/bridge-host-dir.test.mjs test/skill-docs.test.mjs test/dialogs.test.mjs test/emulate.test.mjs test/frames-snapshot.test.mjs test/frames-snapshot.live-attach.test.mjs test/har.test.mjs test/human-handoff.test.mjs test/index-exports.test.mjs test/layers.test.mjs test/network-snoop.test.mjs test/routes.test.mjs test/routes.live.test.mjs test/scroll-collect.test.mjs test/trace.test.mjs test/trace.live.test.mjs test/bsk-ipc-client.test.mjs test/bsk-session.test.mjs test/bsk-snapshot.test.mjs test/bsk-external-extension.test.mjs test/bsk-browser-choice.test.mjs test/bsk-doctor.test.mjs test/bsk-daemon-start.test.mjs", "test:cloak-profile": "node --test test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs", "test:cloak-profile:bun": "bun test test/cloak-profile.test.mjs test/cloak-profile.integration.test.mjs" }, diff --git a/skills/omowright/references/browserskill.md b/skills/omowright/references/browserskill.md index f958ed6..100442a 100644 --- a/skills/omowright/references/browserskill.md +++ b/skills/omowright/references/browserskill.md @@ -27,7 +27,11 @@ await session.stop(); // ALWAYS: returns borrowed `connectBrowserSkill` talks to the daemon over its Unix-socket IPC directly — no `bsk` process per call. It starts the daemon if needed (`bsk status`), -unless `BSK_AUTO_START=0`. It never falls back to a headless browser: when no +unless `BSK_AUTO_START=0`. On Windows, a host that forbids Job Object +breakaway makes `bsk status` refuse to detach; the library then registers a +per-user `bsk-daemon-` scheduled task that runs `bsk daemon start +--foreground` under `conhost.exe --headless`, so no console window opens. +It never falls back to a headless browser: when no extension is connected it throws `no_browser_connected` and `bskDoctor()` tells you the one step that is missing. diff --git a/src/bsk/daemon-info.js b/src/bsk/daemon-info.js index 7cc47a6..17647bf 100644 --- a/src/bsk/daemon-info.js +++ b/src/bsk/daemon-info.js @@ -5,6 +5,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; export function resolveBskHome(env = process.env) { const override = env.BSK_HOME; @@ -36,25 +37,54 @@ export function findBskBinary(env = process.env) { return "bsk"; } -export function startDaemonWithCli({ home, env = process.env, bskBin = findBskBinary(env), timeoutMs = 20_000 } = {}) { +// bsk refuses to detach its daemon when the caller's Job Object forbids breakaway, as agent +// sandboxes on Windows do, and asks for `bsk daemon start --foreground` in a persistent host task. +const NO_BREAKAWAY = /Job Object/; + +function windowsDaemonTaskName(home) { + return `bsk-daemon-${createHash("sha256").update(path.resolve(home).toLowerCase()).digest("hex").slice(0, 12)}`; +} + +// Task Scheduler starts the action outside the caller's job. conhost --headless gives the console +// program a console without a window; a task that starts bsk directly opens one in the default +// terminal, and Windows Terminal ignores the hidden-window flag a task or PowerShell passes. +function startDaemonInWindowsTask({ home, bskBin, timeoutMs }) { + const script = [ + "$action = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\\conhost.exe') -Argument ('--headless cmd.exe /d /c set BSK_HOME=' + $env:BSK_TASK_HOME + '&& \"' + $env:BSK_TASK_BIN + '\" daemon start --foreground')", + "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew", + "Register-ScheduledTask -TaskName $env:BSK_TASK_NAME -Action $action -Settings $settings -Force | Out-Null", + "Start-ScheduledTask -TaskName $env:BSK_TASK_NAME", + ].join("; "); + return runToExit("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + env: { ...process.env, BSK_TASK_NAME: windowsDaemonTaskName(home), BSK_TASK_HOME: home, BSK_TASK_BIN: bskBin }, + timeoutMs, + label: "registering the bsk daemon task", + }); +} + +function runToExit(command, args, { env, timeoutMs, label }) { return new Promise((resolve, reject) => { - const child = spawn(bskBin, ["status", "--json"], { - env: { ...env, BSK_HOME: home }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr = (stderr + chunk).slice(-4096); }); - child.stdout.on("data", () => {}); + const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + let output = ""; + const keep = (chunk) => { output = (output + chunk).slice(-4096); }; + child.stdout.on("data", keep); + child.stderr.on("data", keep); const timer = setTimeout(() => { child.kill("SIGKILL"); - reject(new Error(`bsk status did not return within ${timeoutMs}ms`)); + reject(new Error(`${label} did not return within ${timeoutMs}ms`)); }, timeoutMs); - child.on("error", (error) => { clearTimeout(timer); reject(new Error(`could not run ${bskBin}: ${error.message}`)); }); - child.on("exit", (code) => { - clearTimeout(timer); - if (code === 0) resolve(); - else reject(new Error(`bsk status exited with ${code}${stderr ? `: ${stderr.trim()}` : ""}`)); - }); + child.on("error", (error) => { clearTimeout(timer); reject(new Error(`could not run ${command}: ${error.message}`)); }); + child.on("exit", (code) => { clearTimeout(timer); resolve({ code, output: output.trim() }); }); }); } + +export async function startDaemonWithCli({ home, env = process.env, bskBin = findBskBinary(env), timeoutMs = 20_000 } = {}) { + const status = await runToExit(bskBin, ["status", "--json"], { env: { ...env, BSK_HOME: home }, timeoutMs, label: "bsk status" }); + if (status.code === 0) return; + if (process.platform === "win32" && NO_BREAKAWAY.test(status.output)) { + const task = await startDaemonInWindowsTask({ home, bskBin, timeoutMs }); + if (task.code === 0) return; + throw new Error(`registering the bsk daemon task exited with ${task.code}${task.output ? `: ${task.output}` : ""}`); + } + throw new Error(`bsk status exited with ${status.code}${status.output ? `: ${status.output}` : ""}`); +} diff --git a/test/bsk-daemon-start.test.mjs b/test/bsk-daemon-start.test.mjs new file mode 100644 index 0000000..ba009a8 --- /dev/null +++ b/test/bsk-daemon-start.test.mjs @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { startDaemonWithCli } from "../src/bsk/daemon-info.js"; + +// Platform contract: when bsk cannot detach its daemon from the caller's Job Object, the start +// must still succeed through a scheduled task, and that task must not open a console window. +const REFUSAL = "cannot start an independent Windows daemon; the host may prohibit Job Object breakaway"; +const refusingStatus = `data:text/javascript,console.log(${encodeURIComponent(JSON.stringify(JSON.stringify({ message: REFUSAL, exit_code: 2 })))});process.exit(2)`; + +function powershell(command) { + return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8" }).trim(); +} + +test("a refused breakaway on Windows registers a headless scheduled task for the daemon", { skip: process.platform !== "win32" }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "bsk-task-")); + const home = path.join(dir, "home"); + const findTask = `Get-ScheduledTask -TaskName 'bsk-daemon-*' | Where-Object { $_.Actions[0].Arguments -like '*${home}*' }`; + try { + await startDaemonWithCli({ home, bskBin: "node", env: { ...process.env, NODE_OPTIONS: `--import=${refusingStatus}` } }); + const [execute, args] = powershell(`$a = @(${findTask})[0].Actions[0]; $a.Execute + '|' + $a.Arguments`).split("|"); + assert.match(execute, /\\conhost\.exe$/i); + assert.equal(args, `--headless cmd.exe /d /c set BSK_HOME=${home}&& "node" daemon start --foreground`); + } finally { + powershell(`${findTask} | Unregister-ScheduledTask -Confirm:$false; exit 0`); + rmSync(dir, { recursive: true, force: true }); + } +}); From 4d8291e0b63c7b0656e83aef37a0afdf9678ad20 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 23:12:11 +0900 Subject: [PATCH 2/2] fix(bsk): pass the daemon task's home and binary as PowerShell literals Review on #35: the task action inserted BSK_HOME bare into a cmd.exe line, so a home such as C:\Users\Tom&Jerry split the command. Quoting it was not enough on this path: conhost --headless re-parses the line and still cut it at &, ( and ^ inside the quotes, and cmd.exe expands % even when quoted. The action now runs powershell.exe -EncodedCommand with the home and binary as single-quoted literals, which keep every one of those characters as written. The Windows test compiles a stand-in bsk into a folder named "bin & (x)" and starts it for homes named "Tom&Jerry (x) ^ ;", "100% %PATH% home" and "O'Brien", asserting the registered action and that the daemon start ran with that exact home. It unregisters by the exact task name it computed. --- src/bsk/daemon-info.js | 14 ++++++-- test/bsk-daemon-start.test.mjs | 63 ++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/bsk/daemon-info.js b/src/bsk/daemon-info.js index 17647bf..3148c1b 100644 --- a/src/bsk/daemon-info.js +++ b/src/bsk/daemon-info.js @@ -41,22 +41,30 @@ export function findBskBinary(env = process.env) { // sandboxes on Windows do, and asks for `bsk daemon start --foreground` in a persistent host task. const NO_BREAKAWAY = /Job Object/; -function windowsDaemonTaskName(home) { +export function windowsDaemonTaskName(home) { return `bsk-daemon-${createHash("sha256").update(path.resolve(home).toLowerCase()).digest("hex").slice(0, 12)}`; } // Task Scheduler starts the action outside the caller's job. conhost --headless gives the console // program a console without a window; a task that starts bsk directly opens one in the default // terminal, and Windows Terminal ignores the hidden-window flag a task or PowerShell passes. +// The home and binary travel as PowerShell single-quoted literals inside -EncodedCommand: conhost +// re-parses a cmd.exe line and drops it at the first &, (, ^ or |, and cmd.exe expands % in quotes. +function windowsDaemonLaunch(home, bskBin) { + const literal = (value) => `'${value.replaceAll("'", "''")}'`; + const script = `$env:BSK_HOME = ${literal(home)}; & ${literal(bskBin)} daemon start --foreground; exit $LASTEXITCODE`; + return `--headless powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`; +} + function startDaemonInWindowsTask({ home, bskBin, timeoutMs }) { const script = [ - "$action = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\\conhost.exe') -Argument ('--headless cmd.exe /d /c set BSK_HOME=' + $env:BSK_TASK_HOME + '&& \"' + $env:BSK_TASK_BIN + '\" daemon start --foreground')", + "$action = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\\conhost.exe') -Argument $env:BSK_TASK_ARGUMENT", "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew", "Register-ScheduledTask -TaskName $env:BSK_TASK_NAME -Action $action -Settings $settings -Force | Out-Null", "Start-ScheduledTask -TaskName $env:BSK_TASK_NAME", ].join("; "); return runToExit("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { - env: { ...process.env, BSK_TASK_NAME: windowsDaemonTaskName(home), BSK_TASK_HOME: home, BSK_TASK_BIN: bskBin }, + env: { ...process.env, BSK_TASK_NAME: windowsDaemonTaskName(home), BSK_TASK_ARGUMENT: windowsDaemonLaunch(home, bskBin) }, timeoutMs, label: "registering the bsk daemon task", }); diff --git a/test/bsk-daemon-start.test.mjs b/test/bsk-daemon-start.test.mjs index ba009a8..62fdf43 100644 --- a/test/bsk-daemon-start.test.mjs +++ b/test/bsk-daemon-start.test.mjs @@ -1,31 +1,58 @@ -import { test } from "node:test"; +import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { startDaemonWithCli } from "../src/bsk/daemon-info.js"; +import { startDaemonWithCli, windowsDaemonTaskName } from "../src/bsk/daemon-info.js"; // Platform contract: when bsk cannot detach its daemon from the caller's Job Object, the start -// must still succeed through a scheduled task, and that task must not open a console window. +// must still succeed through a scheduled task that opens no console window, whatever the home path holds. +const windowsOnly = { skip: process.platform !== "win32" }; const REFUSAL = "cannot start an independent Windows daemon; the host may prohibit Job Object breakaway"; -const refusingStatus = `data:text/javascript,console.log(${encodeURIComponent(JSON.stringify(JSON.stringify({ message: REFUSAL, exit_code: 2 })))});process.exit(2)`; + +// A compiled stand-in for bsk: `status` refuses breakaway, `daemon start` records what the task gave it. +const FAKE_BSK = `import { writeFileSync } from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +if (args[0] === "status") { console.log(JSON.stringify({ message: ${JSON.stringify(REFUSAL)}, exit_code: 2 })); process.exit(2); } +writeFileSync(path.join(process.env.BSK_HOME, "started.txt"), args.join(" ")); +`; + +let dir; +let bskBin; function powershell(command) { return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8" }).trim(); } -test("a refused breakaway on Windows registers a headless scheduled task for the daemon", { skip: process.platform !== "win32" }, async () => { - const dir = mkdtempSync(path.join(tmpdir(), "bsk-task-")); - const home = path.join(dir, "home"); - const findTask = `Get-ScheduledTask -TaskName 'bsk-daemon-*' | Where-Object { $_.Actions[0].Arguments -like '*${home}*' }`; - try { - await startDaemonWithCli({ home, bskBin: "node", env: { ...process.env, NODE_OPTIONS: `--import=${refusingStatus}` } }); - const [execute, args] = powershell(`$a = @(${findTask})[0].Actions[0]; $a.Execute + '|' + $a.Arguments`).split("|"); - assert.match(execute, /\\conhost\.exe$/i); - assert.equal(args, `--headless cmd.exe /d /c set BSK_HOME=${home}&& "node" daemon start --foreground`); - } finally { - powershell(`${findTask} | Unregister-ScheduledTask -Confirm:$false; exit 0`); - rmSync(dir, { recursive: true, force: true }); - } +before(() => { + if (process.platform !== "win32") return; + dir = mkdtempSync(path.join(tmpdir(), "bsk-task-")); + writeFileSync(path.join(dir, "fake-bsk.mjs"), FAKE_BSK); + mkdirSync(path.join(dir, "bin & (x)")); + bskBin = path.join(dir, "bin & (x)", "bsk.exe"); + execFileSync("bun", ["build", "--compile", path.join(dir, "fake-bsk.mjs"), "--outfile", bskBin], { stdio: "ignore" }); }); + +after(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); + +for (const name of ["Tom&Jerry (x) ^ ;", "100% %PATH% home", "O'Brien"]) { + test(`a refused breakaway starts the daemon from a headless task for a home named ${name}`, windowsOnly, async () => { + const home = path.join(dir, name); + mkdirSync(home); + const task = windowsDaemonTaskName(home); + try { + await startDaemonWithCli({ home, bskBin }); + const [execute, args] = powershell(`$a = (Get-ScheduledTask -TaskName '${task}').Actions[0]; $a.Execute + '|' + $a.Arguments`).split("|"); + assert.match(execute, /\\conhost\.exe$/i); + assert.match(args, /^--headless powershell\.exe -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/); + const marker = path.join(home, "started.txt"); + const deadline = Date.now() + 15_000; + while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(readFileSync(marker, "utf8"), "daemon start --foreground"); + } finally { + powershell(`Unregister-ScheduledTask -TaskName '${task}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0`); + } + }); +}