Browser skill (#485)

* Add `BrowserTool`

* add some logging

* go with npm install -g

* remove dep changes since the switch to npm install -g

* tweak

* tweak

* tweak

* tweak

* tweak timeout

* tweak

* remove logs

* skill investigation doc

* wip

* wip

* tweak

* lock agent-browser version

* tweak

* logs

* logs

* more logs

* more debug stuff

* try this

* try this

* try this

* fix PATH

* try this

* tweak

* tweak

* tweak

* update wiki entries

* update wiki once again

* lint fix

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
This commit is contained in:
Mateusz Burzyński
2026-03-25 19:36:13 +00:00
committed by pullfrog[bot]
parent 6b18b6730b
commit c0f6f9ef2a
12 changed files with 2569 additions and 2097 deletions
+35 -9
View File
@@ -10,7 +10,7 @@
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
* security is enforced at the tool layer, not the process layer.
*/
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
@@ -21,16 +21,14 @@ import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { type AgentResult, type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
// pinned CLI version
const OPENCODE_CLI_VERSION = "1.1.56";
async function installOpencodeCli(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: OPENCODE_CLI_VERSION,
version: getDevDependencyVersion("opencode-ai"),
executablePath: "bin/opencode",
installDependencies: true,
});
@@ -55,6 +53,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
read: "allow",
webfetch: "allow",
external_directory: "deny",
skill: "allow",
},
mcp: {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
@@ -175,6 +174,23 @@ function detectProviderError(text: string): string | null {
return null;
}
function addSkill(params: { ref: string; skill: string; env: Record<string, string> }): void {
const result = spawnSync(
"npx",
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", "opencode", "-y"],
{
env: { ...process.env, ...params.env },
stdio: "pipe",
timeout: 30_000,
}
);
if (result.status === 0) {
log.info(`installed ${params.skill} skill`);
} else {
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`);
}
}
// ── NDJSON event types ─────────────────────────────────────────────────────────
interface OpenCodeInitEvent {
@@ -652,8 +668,19 @@ export const opentoad = agent({
modelSlug: ctx.payload.model,
});
const tempHome = ctx.tmpdir;
mkdirSync(join(tempHome, ".config", "opencode"), { recursive: true });
const homeEnv = {
HOME: ctx.tmpdir,
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
};
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
addSkill({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser",
env: homeEnv,
});
const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"];
@@ -661,8 +688,7 @@ export const opentoad = agent({
// security is enforced via OPENCODE_CONFIG_CONTENT (bash: deny) and MCP tool filtering.
const env: Record<string, string | undefined> = {
...process.env,
HOME: tempHome,
XDG_CONFIG_HOME: join(tempHome, ".config"),
...homeEnv,
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
GOOGLE_GENERATIVE_AI_API_KEY:
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
+2217 -2065
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -25743,6 +25743,7 @@ async function apiFetch(options) {
}
// utils/retry.ts
import { setTimeout as sleep } from "node:timers/promises";
var defaultShouldRetry = (error2) => {
if (!(error2 instanceof Error)) return false;
return error2.name === "AbortError" || error2.message.includes("fetch failed") || error2.message.includes("ECONNRESET") || error2.message.includes("ETIMEDOUT");
@@ -25763,7 +25764,7 @@ async function retry(fn, options = {}) {
}
const delay = delayMs * attempt;
log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await sleep(delay);
}
}
throw lastError;
+7 -1
View File
@@ -1,11 +1,13 @@
// this must be imported first
import "./arkConfig.ts";
import { createServer } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import { FastMCP, type Tool } from "fastmcp";
import type { AgentUsage } from "../agents/index.ts";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import { closeBrowserDaemon } from "../utils/browser.ts";
import { log } from "../utils/cli.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
@@ -51,6 +53,8 @@ export type BackgroundProcess = {
pidPath: string;
};
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
export type StoredPushDest = {
remoteName: string;
remoteBranch: string;
@@ -70,6 +74,7 @@ export interface ToolState {
checkoutSha?: string;
selectedMode?: string;
backgroundProcesses: Map<string, BackgroundProcess>;
browserDaemon?: BrowserDaemon | undefined;
review?: {
id: number;
nodeId: string;
@@ -319,7 +324,7 @@ async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
// already dead
}
}
await new Promise((resolve) => setTimeout(resolve, 200));
await sleep(200);
for (const proc of backgroundProcesses.values()) {
try {
process.kill(-proc.pid, "SIGKILL");
@@ -347,6 +352,7 @@ export async function startMcpHttpServer(
return {
url: startResult.url,
[Symbol.asyncDispose]: async () => {
closeBrowserDaemon(ctx.toolState);
await killBackgroundProcesses(ctx.toolState);
await startResult.server.stop();
},
+24 -2
View File
@@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs";
import { userInfo } from "node:os";
import { join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { type } from "arktype";
import { ensureBrowserDaemon } from "../utils/browser.ts";
import { log } from "../utils/log.ts";
import { resolveEnv } from "../utils/secrets.ts";
import type { ToolContext } from "./server.ts";
@@ -115,7 +117,12 @@ function spawnShell(params: SpawnParams): ChildProcess {
// sudo is only needed for unshare; the actual command should run as the normal user
// to avoid ownership mismatches with files created by the Node.js parent process.
const username = userInfo().username;
const escaped = params.command.replace(/'/g, "'\\''");
// su -p resets PATH on many Linux systems (ALWAYS_SET_PATH in /etc/login.defs).
// restore it from the SANDBOX_PATH env var that survives the su transition.
// biome-ignore lint/suspicious/noTemplateCurlyInString: we need to restore the PATH variable
const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
return spawn(
"sudo",
[
@@ -195,6 +202,21 @@ Do NOT use this tool for git commands — use the dedicated git tools instead.`,
const cwd = params.working_directory ?? process.cwd();
const env = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
if (params.command.includes("agent-browser")) {
const daemonError = ensureBrowserDaemon(ctx.toolState);
if (daemonError) {
return {
output: `browser daemon unavailable: ${daemonError}`,
exit_code: 1,
timed_out: false,
};
}
const binDir = ctx.toolState.browserDaemon?.binDir;
if (binDir) {
env.PATH = `${binDir}:${env.PATH ?? ""}`;
}
}
if (params.background) {
const tempDir = getTempDir();
const handle = `bg-${randomUUID().slice(0, 8)}`;
@@ -305,7 +327,7 @@ export function KillBackgroundTool(ctx: ToolContext) {
} catch {
// already dead
}
await new Promise((resolve) => setTimeout(resolve, 200));
await sleep(200);
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
+2 -1
View File
@@ -31,7 +31,6 @@
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
"ajv": "^8.18.0",
@@ -47,6 +46,7 @@
"turndown": "^7.2.0"
},
"devDependencies": {
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
@@ -54,6 +54,7 @@
"arg": "^5.0.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"opencode-ai": "1.1.56",
"typescript": "^5.9.3",
"vitest": "^4.0.17",
"yaml": "^2.8.2"
+133 -15
View File
@@ -26,9 +26,6 @@ importers:
'@octokit/webhooks-types':
specifier: ^7.6.1
version: 7.6.1
'@opencode-ai/sdk':
specifier: ^1.0.143
version: 1.0.143
'@standard-schema/spec':
specifier: 1.1.0
version: 1.1.0
@@ -81,6 +78,9 @@ importers:
'@types/turndown':
specifier: ^5.0.5
version: 5.0.6
agent-browser:
specifier: 0.21.0
version: 0.21.0
arg:
specifier: ^5.0.2
version: 5.0.2
@@ -90,12 +90,15 @@ importers:
husky:
specifier: ^9.0.0
version: 9.1.7
opencode-ai:
specifier: 1.1.56
version: 1.1.56
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.0.17
version: 4.0.17(@types/node@24.7.2)(yaml@2.8.2)
version: 4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -547,9 +550,6 @@ packages:
'@octokit/webhooks-types@7.6.1':
resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==}
'@opencode-ai/sdk@1.0.143':
resolution: {integrity: sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==}
'@rollup/rollup-android-arm-eabi@4.55.1':
resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
cpu: [arm]
@@ -753,6 +753,10 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
agent-browser@0.21.0:
resolution: {integrity: sha512-isVHEeb2WL5hLhr4o+zNmcYwmBrldxvrH+FIoRoUmDxyrHr3bhIS6L8BlUMHqT77YtkPq0YSmwoBRrwqeouw9Q==}
hasBin: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -1209,6 +1213,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
@@ -1316,6 +1324,65 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
opencode-ai@1.1.56:
resolution: {integrity: sha512-OAF0G/1jVXpOrCh++M5gFEZ0bRLiXOnbxSYMFx5TOoD0OhCjHJS1JlARzaLDAx461qBnn+jocI9BBxD0wwFH3Q==}
hasBin: true
opencode-darwin-arm64@1.1.56:
resolution: {integrity: sha512-0HqvLm7tcYZr4VJgEzi3Wicia5M9yCX66O7Cv470Qu4+GGbCC2sTTmzQu6pCehSleYgkZsiSBNRcFcW/6F7v0Q==}
cpu: [arm64]
os: [darwin]
opencode-darwin-x64-baseline@1.1.56:
resolution: {integrity: sha512-Z9QO9cTC9TnlUxTfEtDbvZbRFxc5Je8rs2Ei0cLH46W9gHGhh1fW4oX64tcrlhF5NUDKJzr/qwdoMfmKkeu53A==}
cpu: [x64]
os: [darwin]
opencode-darwin-x64@1.1.56:
resolution: {integrity: sha512-8ZhNd4sFbDviT1OJs42C35Bx2/z6mkcb7uNhPHX3KKm27KOm7cYjFQa1UTF5or3ZpjkjPQpR/cpo7TELvnnmRA==}
cpu: [x64]
os: [darwin]
opencode-linux-arm64-musl@1.1.56:
resolution: {integrity: sha512-f30SmYX4xE2fUsnNl66dDX++8iTMI9PLXz7BRHhgXL4XAdMpUUJbOKIsw3ZUB0KWHwjR8cjkEJQOZxR3HPbrZg==}
cpu: [arm64]
os: [linux]
opencode-linux-arm64@1.1.56:
resolution: {integrity: sha512-wEmIEXiEKghurQYgKY5yFUFOmnT/QIlAbYnVeH73gSqOwlYFUUlowXLDJnF+3OGd6m000qalhCxjExL7qKINWw==}
cpu: [arm64]
os: [linux]
opencode-linux-x64-baseline-musl@1.1.56:
resolution: {integrity: sha512-EC8EGTJCgTZgGIMZdQjOKGXw02+igo0am1Ry6wrPrB0li7XBlYbY6dz8tL3FlknRVMLamtGtb3tXQ6tqbwjX0g==}
cpu: [x64]
os: [linux]
opencode-linux-x64-baseline@1.1.56:
resolution: {integrity: sha512-jKOvhkNLcn0h6zmKX+hDdkODijSVDxjAGTZp8BPbJCCJVxnKll9dYfDMTqxi3YN6yXp6sBvyaJT1mcpf8knkeA==}
cpu: [x64]
os: [linux]
opencode-linux-x64-musl@1.1.56:
resolution: {integrity: sha512-9BFYBPgpY2RrUd7/Ul0VNh+6B9l0FmCNRUV4yYmeFGee9ZDgPem5YGicsKAMQqS/5X0jW3ZWje+KBbBQz4RMwQ==}
cpu: [x64]
os: [linux]
opencode-linux-x64@1.1.56:
resolution: {integrity: sha512-0FP3BzLjn+a9naTtm9hpHibJ+eV1RkI+tItcDQXKwHtvzoBUvytDRP8v6TotEHNtNF0ZLAb3OGurwDIIdtLarg==}
cpu: [x64]
os: [linux]
opencode-windows-x64-baseline@1.1.56:
resolution: {integrity: sha512-zEokdohfoDjWzwULvlSq0Y/tRoVmz2/6GofYO936buPQf09cBcPqpAgLUjZV+pQI0Atyd05YG3BCO4PQxmXzdw==}
cpu: [x64]
os: [win32]
opencode-windows-x64@1.1.56:
resolution: {integrity: sha512-zm/oaWT5uGrW3DumKRHiqv2L2pKwrTvhsT4XUSfIPLQn5EvrYNU6bh3WFO4v71ZBjSIKx5Q7rEvTEQvFiFEZFQ==}
cpu: [x64]
os: [win32]
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -2060,8 +2127,6 @@ snapshots:
'@octokit/webhooks-types@7.6.1': {}
'@opencode-ai/sdk@1.0.143': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
optional: true
@@ -2182,13 +2247,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))':
'@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.17
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
'@vitest/pretty-format@4.0.17':
dependencies:
@@ -2222,6 +2287,8 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
agent-browser@0.21.0: {}
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -2752,6 +2819,9 @@ snapshots:
isexe@2.0.0: {}
jiti@2.6.1:
optional: true
jose@6.1.3: {}
jose@6.2.0: {}
@@ -2855,6 +2925,53 @@ snapshots:
dependencies:
wrappy: 1.0.2
opencode-ai@1.1.56:
optionalDependencies:
opencode-darwin-arm64: 1.1.56
opencode-darwin-x64: 1.1.56
opencode-darwin-x64-baseline: 1.1.56
opencode-linux-arm64: 1.1.56
opencode-linux-arm64-musl: 1.1.56
opencode-linux-x64: 1.1.56
opencode-linux-x64-baseline: 1.1.56
opencode-linux-x64-baseline-musl: 1.1.56
opencode-linux-x64-musl: 1.1.56
opencode-windows-x64: 1.1.56
opencode-windows-x64-baseline: 1.1.56
opencode-darwin-arm64@1.1.56:
optional: true
opencode-darwin-x64-baseline@1.1.56:
optional: true
opencode-darwin-x64@1.1.56:
optional: true
opencode-linux-arm64-musl@1.1.56:
optional: true
opencode-linux-arm64@1.1.56:
optional: true
opencode-linux-x64-baseline-musl@1.1.56:
optional: true
opencode-linux-x64-baseline@1.1.56:
optional: true
opencode-linux-x64-musl@1.1.56:
optional: true
opencode-linux-x64@1.1.56:
optional: true
opencode-windows-x64-baseline@1.1.56:
optional: true
opencode-windows-x64@1.1.56:
optional: true
package-manager-detector@1.6.0: {}
parse-ms@4.0.0: {}
@@ -3159,7 +3276,7 @@ snapshots:
vary@1.1.2: {}
vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2):
vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
@@ -3170,12 +3287,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.7.2
fsevents: 2.3.3
jiti: 2.6.1
yaml: 2.8.2
vitest@4.0.17(@types/node@24.7.2)(yaml@2.8.2):
vitest@4.0.17(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(yaml@2.8.2))
'@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.17
'@vitest/runner': 4.0.17
'@vitest/snapshot': 4.0.17
@@ -3192,7 +3310,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@24.7.2)(yaml@2.8.2)
vite: 7.3.1(@types/node@24.7.2)(jiti@2.6.1)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.7.2
+2 -1
View File
@@ -41587,7 +41587,6 @@ var package_default = {
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
ajv: "^8.18.0",
@@ -41603,6 +41602,7 @@ var package_default = {
turndown: "^7.2.0"
},
devDependencies: {
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
@@ -41610,6 +41610,7 @@ var package_default = {
arg: "^5.0.2",
esbuild: "^0.25.9",
husky: "^9.0.0",
"opencode-ai": "1.1.56",
typescript: "^5.9.3",
vitest: "^4.0.17",
yaml: "^2.8.2"
+133
View File
@@ -0,0 +1,133 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
import { filterEnv } from "./secrets.ts";
import { getDevDependencyVersion } from "./version.ts";
// agent-browser already discovers chrome via `which` and the playwright cache as fallbacks,
// so this list only needs to cover the GHA ubuntu-latest runner where we know the exact path.
const CHROME_PATHS = ["/usr/bin/google-chrome-stable"];
let systemChromePath: string | undefined;
function findSystemChromePath(): string | undefined {
if (typeof systemChromePath === "string") {
// return cached result but normalize to undefined if empty
return systemChromePath || undefined;
}
for (const p of CHROME_PATHS) {
if (existsSync(p)) {
systemChromePath = p;
log.info(`found system chrome: ${p}`);
return p;
}
}
// set to an empty string to indicate no system chrome found
// and to avoid repeated lookups
systemChromePath = "";
log.info(`no system chrome found (checked: ${CHROME_PATHS.join(", ")})`);
}
function buildEnv(): Record<string, string> {
const env: Record<string, string> = { ...filterEnv() };
const chromePath = findSystemChromePath();
if (chromePath) {
env.AGENT_BROWSER_EXECUTABLE_PATH = chromePath;
}
return env;
}
/**
* ensure the agent-browser daemon is running by issuing a real command.
*
* agent-browser is stateful — it manages a persistent browser process via a
* daemon that communicates over a Unix socket. we start the daemon here,
* outside of ShellTool, because ShellTool's child process lifecycle would
* kill it between invocations and the daemon must survive across calls.
*
* despite ShellTool commands running inside unshare-sandboxed namespaces,
* they can still reach this daemon because the Unix socket is discoverable
* regardless of unshare's PID/mount isolation. starting the daemon in the
* host namespace keeps it alive while sandboxed shells come and go.
*
* agent-browser auto-starts its daemon on the first CLI invocation and
* keeps it alive via the socket for subsequent commands.
* we run `open about:blank` as the seed command to trigger this.
* idempotent — only runs once.
*/
export function ensureBrowserDaemon(toolState: ToolState): string | undefined {
if (toolState.browserDaemon) {
return toolState.browserDaemon.error;
}
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
log.info(`installing agent-browser@${agentBrowserVersion}...`);
const install = spawnSync("npm", ["install", "-g", `agent-browser@${agentBrowserVersion}`], {
stdio: "pipe",
encoding: "utf-8",
});
if (install.status !== 0) {
const error = `agent-browser install failed: ${(install.stderr || install.stdout || "unknown error").trim()}`;
log.error(error);
toolState.browserDaemon = { error };
return error;
}
log.info("agent-browser installed");
let binDir: string;
try {
const binPath = execFileSync("which", ["agent-browser"], { encoding: "utf-8" }).trim();
binDir = dirname(binPath);
log.info(`agent-browser binary: ${binPath}`);
} catch {
const error = "agent-browser binary not found in PATH after install";
log.error(error);
toolState.browserDaemon = { error };
return error;
}
const env = buildEnv();
// `open about:blank` triggers daemon auto-start and returns once the daemon + browser are ready
log.info("starting browser daemon...");
const seed = spawnSync("agent-browser", ["open", "about:blank"], {
env,
stdio: "pipe",
encoding: "utf-8",
timeout: 30_000,
});
if (seed.status !== 0) {
const output = (seed.stderr || seed.stdout || "unknown error").trim();
const error = `agent-browser open about:blank failed (exit=${seed.status}): ${output}`;
log.error(error);
toolState.browserDaemon = { error };
return error;
}
log.debug(`seed command done (exit=0): ${(seed.stdout || "").trim()}`);
toolState.browserDaemon = { binDir };
log.info("browser daemon ready");
}
export function closeBrowserDaemon(toolState: ToolState): void {
if (!toolState.browserDaemon?.binDir) {
delete toolState.browserDaemon;
return;
}
delete toolState.browserDaemon;
try {
log.info("closing browser daemon...");
spawnSync("agent-browser", ["close"], {
env: filterEnv(),
stdio: "pipe",
timeout: 10_000,
});
log.info("browser daemon closed");
} catch {
// best-effort
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { log } from "./cli.ts";
export interface InstallFromNpmTarballParams {
@@ -172,7 +173,7 @@ async function fetchWithRetry(
const waitSeconds = parseInt(retryAfter, 10);
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
log.info(`» rate limited, waiting ${waitSeconds} seconds before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
await sleep(waitSeconds * 1000);
const retryResponse = await fetch(url, { headers });
if (!retryResponse.ok) {
throw new Error(
+2 -1
View File
@@ -1,3 +1,4 @@
import { setTimeout as sleep } from "node:timers/promises";
import { log } from "./cli.ts";
export type RetryOptions = {
@@ -38,7 +39,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
const delay = delayMs * attempt;
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
await sleep(delay);
}
}
+10
View File
@@ -0,0 +1,10 @@
import semver from "semver";
import packageJson from "../package.json" with { type: "json" };
export function getDevDependencyVersion(name: keyof typeof packageJson.devDependencies): string {
const version = packageJson.devDependencies[name];
if (!semver.valid(version)) {
throw new Error(`dev dependency "${name}" must be a pinned version, got "${version}"`);
}
return version;
}