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..3148c1b 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,62 @@ 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/; + +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 $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_ARGUMENT: windowsDaemonLaunch(home, 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..62fdf43 --- /dev/null +++ b/test/bsk-daemon-start.test.mjs @@ -0,0 +1,58 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +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 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"; + +// 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(); +} + +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`); + } + }); +}