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:
committed by
pullfrog[bot]
parent
6b18b6730b
commit
c0f6f9ef2a
+35
-9
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+133
-15
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user