Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 5 additions & 1 deletion skills/omowright/references/browserskill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>` 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.

Expand Down
70 changes: 54 additions & 16 deletions src/bsk/daemon-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}` : ""}`);
}
58 changes: 58 additions & 0 deletions test/bsk-daemon-start.test.mjs
Original file line number Diff line number Diff line change
@@ -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`);
}
});
}
Loading