@proagentstore/cli 0.4.71 → 0.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-runner/errors.js +6 -2
- package/dist/browser-runner/local-browser/bridge-stdio.js +75 -0
- package/dist/browser-runner/local-browser/bridge.js +312 -0
- package/dist/browser-runner/local-browser/contract.js +215 -0
- package/dist/browser-runner/local-browser/engine.js +150 -0
- package/dist/browser-runner/local-browser/runtime.js +449 -0
- package/dist/browser-runner/runner.js +21 -1
- package/dist/browser-runner/server.js +23 -0
- package/dist/browser-runner/task-types.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a research run launches its CLI (#944): arguments, environment and prompt, per engine.
|
|
3
|
+
*
|
|
4
|
+
* The opposite of a coding session's launch. Coder starts its engines with every permission
|
|
5
|
+
* (`--dangerously-skip-permissions`, `--sandbox danger-full-access`, `coding/handlers.ts`) because
|
|
6
|
+
* it edits a repository; a research run must not be able to reach the web any way except through
|
|
7
|
+
* the runner's policy bridge. So:
|
|
8
|
+
*
|
|
9
|
+
* - Claude Code: `--tools ""` removes EVERY built-in tool (WebFetch, WebSearch, Bash, file edits),
|
|
10
|
+
* `--strict-mcp-config` loads only the bridge, `--allowedTools mcp__pags_browser` pre-approves
|
|
11
|
+
* it, and `--permission-mode dontAsk` refuses anything else without stopping to ask.
|
|
12
|
+
* - Codex: `--sandbox read-only` (its shell cannot write, and per Codex's sandbox design has no
|
|
13
|
+
* network), `--ignore-user-config` (no MCP servers or providers from the user's own config — auth
|
|
14
|
+
* still comes from the login), web search switched off, and the bridge as its only MCP server.
|
|
15
|
+
* NOT VERIFIED END TO END on a live machine: that read-only shell commands get no network, and
|
|
16
|
+
* which of the two web-search keys a given Codex version honours. #947 holds that test.
|
|
17
|
+
*
|
|
18
|
+
* Neither ever gets a provider API key unless the owner chose `api-key`: subscription and machine
|
|
19
|
+
* sign-in remove ANTHROPIC_API_KEY and OPENAI_API_KEY from the inherited environment (an empty
|
|
20
|
+
* overlay value means remove — `mergeEnv`), because a key left in a developer's shell silently
|
|
21
|
+
* turns a subscription run into per-token billing.
|
|
22
|
+
*/
|
|
23
|
+
import { mergeEnv } from "../coding/engine-env.js";
|
|
24
|
+
import { resolveEngineAuth } from "../coding/engine-auth.js";
|
|
25
|
+
/** The bridge's MCP server name — and so the prefix of its tools inside the CLI. */
|
|
26
|
+
export const BRIDGE_SERVER_NAME = "pags_browser";
|
|
27
|
+
const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
28
|
+
/** The engine's environment: the machine's, minus provider keys unless the owner chose api-key. */
|
|
29
|
+
export function engineEnv(authMode, engine, toolTimeoutMs, base = process.env) {
|
|
30
|
+
const overlay = {};
|
|
31
|
+
if (authMode !== "api-key")
|
|
32
|
+
for (const k of PROVIDER_KEYS)
|
|
33
|
+
overlay[k] = "";
|
|
34
|
+
// A consent or captcha pause holds the tool call open until the owner acts. Claude Code's
|
|
35
|
+
// default MCP tool timeout would end that call long before; give it the run's own budget.
|
|
36
|
+
if (engine === "claude")
|
|
37
|
+
overlay.MCP_TOOL_TIMEOUT = String(toolTimeoutMs);
|
|
38
|
+
return mergeEnv(base, overlay);
|
|
39
|
+
}
|
|
40
|
+
/** What the engine authenticates with, read from the env it is spawned with. */
|
|
41
|
+
export function observedEngineAuth(engine, env) {
|
|
42
|
+
return resolveEngineAuth(engine, env);
|
|
43
|
+
}
|
|
44
|
+
const toml = (v) => JSON.stringify(v); // a TOML basic string accepts JSON's escapes
|
|
45
|
+
export function buildEngineSpec(input) {
|
|
46
|
+
const env = engineEnv(input.authMode, input.engine, input.toolTimeoutMs, input.baseEnv);
|
|
47
|
+
if (input.engine === "claude") {
|
|
48
|
+
const json = JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: { command: input.bridge.command, args: input.bridge.args, env: input.bridge.env } } });
|
|
49
|
+
return {
|
|
50
|
+
command: "claude",
|
|
51
|
+
args: [
|
|
52
|
+
"-p",
|
|
53
|
+
input.prompt,
|
|
54
|
+
"--output-format",
|
|
55
|
+
"stream-json",
|
|
56
|
+
"--verbose",
|
|
57
|
+
"--tools",
|
|
58
|
+
"",
|
|
59
|
+
"--strict-mcp-config",
|
|
60
|
+
"--mcp-config",
|
|
61
|
+
input.mcpConfigPath,
|
|
62
|
+
"--allowedTools",
|
|
63
|
+
`mcp__${BRIDGE_SERVER_NAME}`,
|
|
64
|
+
"--permission-mode",
|
|
65
|
+
"dontAsk",
|
|
66
|
+
],
|
|
67
|
+
env,
|
|
68
|
+
mcpConfig: { path: input.mcpConfigPath, json },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const envTable = `{ ${Object.entries(input.bridge.env)
|
|
72
|
+
.map(([k, v]) => `${k} = ${toml(v)}`)
|
|
73
|
+
.join(", ")} }`;
|
|
74
|
+
const key = `mcp_servers.${BRIDGE_SERVER_NAME}`;
|
|
75
|
+
return {
|
|
76
|
+
command: "codex",
|
|
77
|
+
args: [
|
|
78
|
+
"exec",
|
|
79
|
+
"--json",
|
|
80
|
+
"--skip-git-repo-check",
|
|
81
|
+
"--sandbox",
|
|
82
|
+
"read-only",
|
|
83
|
+
"--ignore-user-config",
|
|
84
|
+
"-c",
|
|
85
|
+
`${key}.command=${toml(input.bridge.command)}`,
|
|
86
|
+
"-c",
|
|
87
|
+
`${key}.args=[${input.bridge.args.map(toml).join(", ")}]`,
|
|
88
|
+
"-c",
|
|
89
|
+
`${key}.env=${envTable}`,
|
|
90
|
+
"-c",
|
|
91
|
+
`${key}.tool_timeout_sec=${Math.ceil(input.toolTimeoutMs / 1000)}`,
|
|
92
|
+
// Two spellings across Codex versions; an unknown key is ignored, so both are set.
|
|
93
|
+
"-c",
|
|
94
|
+
'web_search="disabled"',
|
|
95
|
+
"-c",
|
|
96
|
+
"tools.web_search=false",
|
|
97
|
+
input.prompt,
|
|
98
|
+
],
|
|
99
|
+
env,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The brief the CLI is given. The rules are restated, but the bridge is what enforces them. */
|
|
103
|
+
export function researchPrompt(e) {
|
|
104
|
+
const t = (name) => `mcp__${BRIDGE_SERVER_NAME}__${name}`;
|
|
105
|
+
const sites = e.policy.allowDomains.length ? `Stay on these sites: ${e.policy.allowDomains.join(", ")}.` : "Any public site may be visited; a site the owner has not approved pauses the run until they decide.";
|
|
106
|
+
return [
|
|
107
|
+
"You are doing read-only web research for the owner of this machine.",
|
|
108
|
+
"",
|
|
109
|
+
`Objective: ${e.objective}`,
|
|
110
|
+
"",
|
|
111
|
+
"How to work:",
|
|
112
|
+
`- Your only tools are the browser tools from the ${BRIDGE_SERVER_NAME} server. Use ${t("browser_navigate")} to open pages (build search URLs with query parameters — you cannot type into pages), ${t("browser_snapshot")} to read them, and ${t("browser_click")} only for links, tabs and pagination.`,
|
|
113
|
+
"- Never try to sign in, submit, apply, buy, post or message anything. Never work around a captcha, a login wall, a paywall or a block — report it.",
|
|
114
|
+
`- ${sites}${e.policy.denyDomains.length ? ` Never visit: ${e.policy.denyDomains.join(", ")}.` : ""}`,
|
|
115
|
+
`- Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
|
|
116
|
+
`- For every result, call ${t("record_finding")} with the page URL and the exact text that supports it. For every source you could not read, call ${t("report_source_failure")}.`,
|
|
117
|
+
`- When done, call ${t("finish_research")} with a short summary of what you found and what you could not reach, then stop.`,
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
const LOGIN_MISSING = {
|
|
121
|
+
claude: /not logged in|please run \/login|invalid api key|oauth token (has )?expired|authentication_error/i,
|
|
122
|
+
codex: /not logged in|run `?codex login|401 unauthorized|please log ?in|token (is )?expired/i,
|
|
123
|
+
};
|
|
124
|
+
/** Is this output the CLI saying it is not signed in? */
|
|
125
|
+
export function missingLogin(engine, output) {
|
|
126
|
+
return LOGIN_MISSING[engine].test(output);
|
|
127
|
+
}
|
|
128
|
+
export function signInHelp(engine) {
|
|
129
|
+
return engine === "claude"
|
|
130
|
+
? "The Claude Code CLI on this machine is not signed in. Run `claude`, then /login, on that machine and start the run again."
|
|
131
|
+
: "The Codex CLI on this machine is not signed in. Run `codex login` on that machine and start the run again.";
|
|
132
|
+
}
|
|
133
|
+
/** The CLI's own closing text, from its structured output — the summary when it never called finish_research. */
|
|
134
|
+
export function finalText(engine, lines) {
|
|
135
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
136
|
+
let ev;
|
|
137
|
+
try {
|
|
138
|
+
ev = JSON.parse(lines[i]);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (engine === "claude" && ev.type === "result" && typeof ev.result === "string")
|
|
144
|
+
return ev.result;
|
|
145
|
+
const item = ev.item;
|
|
146
|
+
if (engine === "codex" && ev.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string")
|
|
147
|
+
return item.text;
|
|
148
|
+
}
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local CLI browser research on the runner (#944) — the run registry and its lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* One run = one Codex or Claude Code process, signed in on this machine, in its own working folder
|
|
5
|
+
* (a managed scratch folder by default; never a repository), whose only window on the web is the
|
|
6
|
+
* policy bridge (bridge.ts). The runtime owns everything PAGS later pulls with `status`:
|
|
7
|
+
*
|
|
8
|
+
* - idempotency: a start with a requestId this runner already holds returns that run;
|
|
9
|
+
* - the concurrency cap the API resolved (`limits.maxConcurrent`), per instance;
|
|
10
|
+
* - the time limit — ACTIVE time only, so a run waiting on the owner is not timed out by the wait;
|
|
11
|
+
* - pauses: a consent, captcha or sign-in pause holds the bridge call until `resume` (or cancel,
|
|
12
|
+
* or the time limit) releases it;
|
|
13
|
+
* - the trace: seq-numbered, redacted events, capped per run;
|
|
14
|
+
* - the result envelope, once the CLI exits;
|
|
15
|
+
* - retention: an ended run's folder and record are removed after `retentionMs`.
|
|
16
|
+
*
|
|
17
|
+
* Runs live in memory. A runner restart loses them, and `status` then answers 404 for the run —
|
|
18
|
+
* which the API reads as "the runner lost this run" and ends it, rather than waiting forever.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join, resolve, sep } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { RunnerInputError } from "../errors.js";
|
|
27
|
+
import { BrowserBridge, domainWithin } from "./bridge.js";
|
|
28
|
+
import { BRIDGE_ENV } from "./bridge-stdio.js";
|
|
29
|
+
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
|
|
30
|
+
import { buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
|
|
31
|
+
const MAX_EVENTS = 2000;
|
|
32
|
+
const OUTPUT_LINES = 400;
|
|
33
|
+
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,100}$/;
|
|
34
|
+
const strList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").map((x) => x.toLowerCase()) : []);
|
|
35
|
+
const posInt = (v, max) => (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max ? v : null);
|
|
36
|
+
/** The envelope, validated at the runner's boundary — PAGS sends it, but the relay is a boundary. */
|
|
37
|
+
export function parseEnvelope(raw) {
|
|
38
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
39
|
+
const bad = (what) => new RunnerInputError(`Invalid local browser task: ${what}`);
|
|
40
|
+
if (o.type !== LOCAL_BROWSER_TASK_TYPE)
|
|
41
|
+
throw bad(`type must be ${LOCAL_BROWSER_TASK_TYPE}`);
|
|
42
|
+
for (const k of ["runId", "requestId", "instanceId"])
|
|
43
|
+
if (typeof o[k] !== "string" || !SAFE_ID.test(o[k]))
|
|
44
|
+
throw bad(`${k} is required`);
|
|
45
|
+
if (typeof o.objective !== "string" || !o.objective.trim() || o.objective.length > 4000)
|
|
46
|
+
throw bad("objective is required");
|
|
47
|
+
if (!LOCAL_BROWSER_ENGINES.includes(o.engine))
|
|
48
|
+
throw bad("engine must be claude or codex");
|
|
49
|
+
if (!LOCAL_BROWSER_AUTH_MODES.includes(o.authMode))
|
|
50
|
+
throw bad("authMode is invalid");
|
|
51
|
+
const ws = (o.workspace && typeof o.workspace === "object" ? o.workspace : {});
|
|
52
|
+
if (ws.kind !== "scratch" && !(ws.kind === "path" && typeof ws.path === "string"))
|
|
53
|
+
throw bad("workspace must be scratch or a path");
|
|
54
|
+
if (o.browserProfile !== "isolated" && o.browserProfile !== "default")
|
|
55
|
+
throw bad("browserProfile must be isolated or default");
|
|
56
|
+
const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
|
|
57
|
+
if (p.mode !== "research_only")
|
|
58
|
+
throw bad("only research_only runs are supported");
|
|
59
|
+
const l = (o.limits && typeof o.limits === "object" ? o.limits : {});
|
|
60
|
+
const limits = {
|
|
61
|
+
maxMinutes: posInt(l.maxMinutes, 60) ?? 0,
|
|
62
|
+
maxPages: posInt(l.maxPages, 200) ?? 0,
|
|
63
|
+
maxActions: posInt(l.maxActions, 1000) ?? 0,
|
|
64
|
+
maxConcurrent: posInt(l.maxConcurrent, 3) ?? 0,
|
|
65
|
+
};
|
|
66
|
+
if (Object.values(limits).some((v) => !v))
|
|
67
|
+
throw bad("limits are out of range");
|
|
68
|
+
const schema = (o.resultSchema && typeof o.resultSchema === "object" ? o.resultSchema : {});
|
|
69
|
+
return {
|
|
70
|
+
type: LOCAL_BROWSER_TASK_TYPE,
|
|
71
|
+
runId: o.runId,
|
|
72
|
+
requestId: o.requestId,
|
|
73
|
+
instanceId: o.instanceId,
|
|
74
|
+
objective: o.objective.trim(),
|
|
75
|
+
engine: o.engine,
|
|
76
|
+
authMode: o.authMode,
|
|
77
|
+
workspace: ws.kind === "path" ? { kind: "path", path: ws.path } : { kind: "scratch" },
|
|
78
|
+
browserProfile: o.browserProfile,
|
|
79
|
+
policy: {
|
|
80
|
+
mode: "research_only",
|
|
81
|
+
allowDomains: strList(p.allowDomains),
|
|
82
|
+
denyDomains: strList(p.denyDomains),
|
|
83
|
+
consentedDomains: strList(p.consentedDomains),
|
|
84
|
+
profileConsented: p.profileConsented === true,
|
|
85
|
+
consentIds: consentIdMap(p.consentIds),
|
|
86
|
+
},
|
|
87
|
+
limits,
|
|
88
|
+
resultSchema: { id: typeof schema.id === "string" ? schema.id : "findings", version: typeof schema.version === "number" ? schema.version : 1 },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Decision ids keyed by lowercase domain (or `*`), from an untrusted object. */
|
|
92
|
+
function consentIdMap(raw) {
|
|
93
|
+
const out = {};
|
|
94
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
95
|
+
for (const [k, v] of Object.entries(raw).slice(0, 200))
|
|
96
|
+
if (typeof v === "string" && SAFE_ID.test(v))
|
|
97
|
+
out[k.toLowerCase()] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A `~/…` workspace, resolved under the home directory — re-checked here against the real disk,
|
|
103
|
+
* because the API could only check the string. A symlink out of home is refused like `..` is.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveWorkspacePath(path, home) {
|
|
106
|
+
if (!/^~\/[^\0]+$/.test(path))
|
|
107
|
+
throw new RunnerInputError('The workspace folder must be written "~/…", under this machine\'s home folder.');
|
|
108
|
+
const realHome = realpathSync(home);
|
|
109
|
+
const target = resolve(realHome, path.slice(2));
|
|
110
|
+
if (!target.startsWith(realHome + sep))
|
|
111
|
+
throw new RunnerInputError("The workspace folder must be inside this machine's home folder.");
|
|
112
|
+
mkdirSync(target, { recursive: true });
|
|
113
|
+
const real = realpathSync(target);
|
|
114
|
+
if (!real.startsWith(realHome + sep))
|
|
115
|
+
throw new RunnerInputError("The workspace folder resolves outside this machine's home folder (a symlink).");
|
|
116
|
+
return real;
|
|
117
|
+
}
|
|
118
|
+
export class LocalBrowserRuntime {
|
|
119
|
+
deps;
|
|
120
|
+
runs = new Map();
|
|
121
|
+
root;
|
|
122
|
+
now;
|
|
123
|
+
spawn;
|
|
124
|
+
retentionMs;
|
|
125
|
+
constructor(deps) {
|
|
126
|
+
this.deps = deps;
|
|
127
|
+
this.root = join(deps.dataDir, "local-browser");
|
|
128
|
+
this.now = deps.now ?? Date.now;
|
|
129
|
+
this.spawn = deps.spawn ?? nodeSpawn;
|
|
130
|
+
this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
|
|
131
|
+
}
|
|
132
|
+
/** Start a run. A requestId already held for the instance returns that run instead. */
|
|
133
|
+
start(raw) {
|
|
134
|
+
const envelope = parseEnvelope(raw);
|
|
135
|
+
this.sweep();
|
|
136
|
+
const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
|
|
137
|
+
if (same)
|
|
138
|
+
return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
|
|
139
|
+
if (this.runs.has(envelope.runId))
|
|
140
|
+
throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
|
|
141
|
+
const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
|
|
142
|
+
if (active >= envelope.limits.maxConcurrent)
|
|
143
|
+
throw new RunnerInputError(`This machine is already running ${active} research run(s) for this agent (limit ${envelope.limits.maxConcurrent}).`, 409);
|
|
144
|
+
const selfUrl = this.deps.selfUrl();
|
|
145
|
+
if (!selfUrl)
|
|
146
|
+
throw new RunnerInputError("The runner is still starting; try again in a moment.", 409);
|
|
147
|
+
const dir = join(this.root, envelope.instanceId, envelope.runId);
|
|
148
|
+
mkdirSync(dir, { recursive: true });
|
|
149
|
+
const workDir = envelope.workspace.kind === "path" ? resolveWorkspacePath(envelope.workspace.path, this.deps.homeDir ?? homedir()) : join(dir, "scratch");
|
|
150
|
+
mkdirSync(workDir, { recursive: true });
|
|
151
|
+
const run = {
|
|
152
|
+
envelope,
|
|
153
|
+
dir,
|
|
154
|
+
workDir,
|
|
155
|
+
token: randomBytes(24).toString("hex"),
|
|
156
|
+
state: "running",
|
|
157
|
+
events: [],
|
|
158
|
+
seq: 0,
|
|
159
|
+
consented: new Set(envelope.policy.consentedDomains),
|
|
160
|
+
deny: new Set(envelope.policy.denyDomains),
|
|
161
|
+
profileConsented: envelope.policy.profileConsented,
|
|
162
|
+
consentIds: new Map(Object.entries(envelope.policy.consentIds ?? {})),
|
|
163
|
+
secrets: secretEnvValues(process.env),
|
|
164
|
+
activeSince: this.now(),
|
|
165
|
+
activeMs: 0,
|
|
166
|
+
waiters: [],
|
|
167
|
+
output: [],
|
|
168
|
+
engineAuth: "unknown",
|
|
169
|
+
cancelled: false,
|
|
170
|
+
timedOut: false,
|
|
171
|
+
};
|
|
172
|
+
this.runs.set(envelope.runId, run);
|
|
173
|
+
// Not awaited: a signed-in-profile consent pause can hold the launch for minutes, and the
|
|
174
|
+
// relay command that started the run must answer now. Everything after this is in `status`.
|
|
175
|
+
void this.launch(run, selfUrl).catch((err) => this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) }));
|
|
176
|
+
return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
|
|
177
|
+
}
|
|
178
|
+
async launch(run, selfUrl) {
|
|
179
|
+
const e = run.envelope;
|
|
180
|
+
// The signed-in profile is the owner's own browser — never without their say-so (#947).
|
|
181
|
+
if (e.browserProfile === "default" && !run.profileConsented) {
|
|
182
|
+
this.emit(run, { type: "consent.requested", detail: { scope: "signed_in_profile" } });
|
|
183
|
+
const outcome = await this.pause(run, "consent_required", { scope: "signed_in_profile" });
|
|
184
|
+
if (outcome !== "resumed" || !run.profileConsented)
|
|
185
|
+
throw new Error("The owner did not allow research in their signed-in browser profile.");
|
|
186
|
+
}
|
|
187
|
+
if (e.browserProfile === "default")
|
|
188
|
+
this.emit(run, { type: "policy.decision", ...this.consentRef(run, "*"), detail: { scope: "signed_in_profile", decision: "consented" } });
|
|
189
|
+
run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
|
|
190
|
+
run.bridge = new BrowserBridge(run.browser.tools, {
|
|
191
|
+
emit: (ev) => this.emit(run, ev),
|
|
192
|
+
pause: (reason, detail) => this.pause(run, reason, detail),
|
|
193
|
+
consentIdFor: (host) => this.consentRef(run, host).consentId,
|
|
194
|
+
isDenied: (host) => [...run.deny].some((d) => domainWithin(host, d)),
|
|
195
|
+
isPermitted: (host) => [...e.policy.allowDomains, ...run.consented].some((d) => domainWithin(host, d)),
|
|
196
|
+
allowListOnly: () => e.policy.allowDomains.length > 0,
|
|
197
|
+
overTime: () => this.activeMs(run) >= e.limits.maxMinutes * 60_000,
|
|
198
|
+
limits: e.limits,
|
|
199
|
+
});
|
|
200
|
+
const bridgeScript = this.deps.bridgeScript ?? fileURLToPath(new URL("./bridge-stdio.js", import.meta.url));
|
|
201
|
+
const spec = buildEngineSpec({
|
|
202
|
+
engine: e.engine,
|
|
203
|
+
authMode: e.authMode,
|
|
204
|
+
prompt: researchPrompt(e),
|
|
205
|
+
bridge: { command: process.execPath, args: [bridgeScript], env: { [BRIDGE_ENV.url]: selfUrl, [BRIDGE_ENV.runId]: e.runId, [BRIDGE_ENV.token]: run.token } },
|
|
206
|
+
mcpConfigPath: join(run.dir, "mcp.json"),
|
|
207
|
+
toolTimeoutMs: e.limits.maxMinutes * 60_000,
|
|
208
|
+
});
|
|
209
|
+
if (spec.mcpConfig)
|
|
210
|
+
writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
|
|
211
|
+
// The env the engine actually gets — a key the owner chose to pass (api-key mode) included.
|
|
212
|
+
run.secrets = secretEnvValues({ ...process.env, ...spec.env });
|
|
213
|
+
run.engineAuth = observedEngineAuth(e.engine, spec.env);
|
|
214
|
+
this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
|
|
215
|
+
const child = this.spawn(spec.command, spec.args, { cwd: run.workDir, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
216
|
+
run.child = child;
|
|
217
|
+
this.emit(run, { type: "engine.started", detail: { engine: e.engine, workspace: e.workspace.kind } });
|
|
218
|
+
let buf = "";
|
|
219
|
+
const keep = (chunk) => {
|
|
220
|
+
buf += chunk.toString();
|
|
221
|
+
const lines = buf.split("\n");
|
|
222
|
+
buf = lines.pop() ?? "";
|
|
223
|
+
for (const line of lines)
|
|
224
|
+
if (line.trim())
|
|
225
|
+
run.output.push(line.slice(0, 20_000));
|
|
226
|
+
if (run.output.length > OUTPUT_LINES)
|
|
227
|
+
run.output.splice(0, run.output.length - OUTPUT_LINES);
|
|
228
|
+
};
|
|
229
|
+
child.stdout?.on("data", keep);
|
|
230
|
+
child.stderr?.on("data", keep);
|
|
231
|
+
child.on("error", (err) => {
|
|
232
|
+
const missing = err.code === "ENOENT";
|
|
233
|
+
this.end(run, { outcome: "failed", error: missing ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
|
|
234
|
+
});
|
|
235
|
+
child.on("close", (code) => {
|
|
236
|
+
if (buf.trim())
|
|
237
|
+
run.output.push(buf);
|
|
238
|
+
this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
|
|
239
|
+
this.finish(run, code ?? -1);
|
|
240
|
+
});
|
|
241
|
+
run.timer = setInterval(() => {
|
|
242
|
+
if (run.state !== "ended" && this.activeMs(run) >= e.limits.maxMinutes * 60_000 + 60_000) {
|
|
243
|
+
// The bridge already refuses past the limit; this is the backstop for a CLI that keeps
|
|
244
|
+
// thinking without calling a tool.
|
|
245
|
+
run.timedOut = true;
|
|
246
|
+
this.kill(run);
|
|
247
|
+
}
|
|
248
|
+
}, 5_000);
|
|
249
|
+
run.timer.unref?.();
|
|
250
|
+
}
|
|
251
|
+
activeMs(run) {
|
|
252
|
+
return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
|
|
253
|
+
}
|
|
254
|
+
emit(run, ev) {
|
|
255
|
+
if (run.events.length >= MAX_EVENTS)
|
|
256
|
+
return;
|
|
257
|
+
const { detail: raw, ...rest } = ev;
|
|
258
|
+
const detail = redactDetail(raw, 0, run.secrets);
|
|
259
|
+
run.events.push({ ...rest, ...(detail && Object.keys(detail).length ? { detail } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
260
|
+
}
|
|
261
|
+
pause(run, reason, detail) {
|
|
262
|
+
if (run.state === "ended" || run.cancelled)
|
|
263
|
+
return Promise.resolve("stopped");
|
|
264
|
+
if (run.state === "running") {
|
|
265
|
+
run.activeMs += this.now() - run.activeSince;
|
|
266
|
+
run.state = "paused";
|
|
267
|
+
run.pauseReason = reason;
|
|
268
|
+
this.emit(run, { type: "run.paused", pauseReason: reason, detail });
|
|
269
|
+
}
|
|
270
|
+
return new Promise((resolveWait) => run.waiters.push(resolveWait));
|
|
271
|
+
}
|
|
272
|
+
release(run, outcome) {
|
|
273
|
+
if (run.state === "paused") {
|
|
274
|
+
run.state = "running";
|
|
275
|
+
run.pauseReason = undefined;
|
|
276
|
+
run.activeSince = this.now();
|
|
277
|
+
if (outcome === "resumed")
|
|
278
|
+
this.emit(run, { type: "run.resumed" });
|
|
279
|
+
}
|
|
280
|
+
for (const w of run.waiters.splice(0))
|
|
281
|
+
w(outcome);
|
|
282
|
+
}
|
|
283
|
+
/** The owner acted: refresh consent and release whatever the run was waiting on. */
|
|
284
|
+
resume(raw) {
|
|
285
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
286
|
+
const run = this.get(String(o.runId ?? ""));
|
|
287
|
+
if (run.state === "ended")
|
|
288
|
+
throw new RunnerInputError("The run has ended", 409);
|
|
289
|
+
for (const [d, id] of Object.entries(consentIdMap(o.consentIds)))
|
|
290
|
+
run.consentIds.set(d, id);
|
|
291
|
+
for (const d of strList(o.consentedDomains))
|
|
292
|
+
run.consented.add(d);
|
|
293
|
+
for (const d of strList(o.denyDomains))
|
|
294
|
+
run.deny.add(d);
|
|
295
|
+
if (o.profileConsented === true)
|
|
296
|
+
run.profileConsented = true;
|
|
297
|
+
this.release(run, "resumed");
|
|
298
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
299
|
+
}
|
|
300
|
+
cancel(raw) {
|
|
301
|
+
const runId = String(raw?.runId ?? "");
|
|
302
|
+
const run = this.get(runId);
|
|
303
|
+
if (run.state !== "ended") {
|
|
304
|
+
run.cancelled = true;
|
|
305
|
+
this.release(run, "stopped");
|
|
306
|
+
if (run.child)
|
|
307
|
+
this.kill(run);
|
|
308
|
+
else
|
|
309
|
+
this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
|
|
310
|
+
}
|
|
311
|
+
return { runId, state: run.state };
|
|
312
|
+
}
|
|
313
|
+
status(raw) {
|
|
314
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
315
|
+
const run = this.get(String(o.runId ?? ""));
|
|
316
|
+
const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
|
|
317
|
+
return {
|
|
318
|
+
runId: run.envelope.runId,
|
|
319
|
+
state: run.state,
|
|
320
|
+
...(run.pauseReason ? { pauseReason: run.pauseReason } : {}),
|
|
321
|
+
events: run.events.filter((e) => e.seq > after).slice(0, 500),
|
|
322
|
+
lastSeq: run.seq,
|
|
323
|
+
...(run.result ? { result: run.result } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Does this token belong to this run? The bridge forwarder's only credential. */
|
|
327
|
+
authorizeBridge(runId, token) {
|
|
328
|
+
const run = this.runs.get(runId);
|
|
329
|
+
return !!run && run.state !== "ended" && token.length > 0 && token === run.token;
|
|
330
|
+
}
|
|
331
|
+
async bridge(raw) {
|
|
332
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
333
|
+
const run = this.get(String(o.runId ?? ""));
|
|
334
|
+
if (!run.bridge || run.state === "ended")
|
|
335
|
+
throw new RunnerInputError("The run is not active", 409);
|
|
336
|
+
if (o.op === "list")
|
|
337
|
+
return { tools: await run.bridge.listTools() };
|
|
338
|
+
if (o.op === "call" && typeof o.name === "string")
|
|
339
|
+
return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
|
|
340
|
+
throw new RunnerInputError("op must be list or call");
|
|
341
|
+
}
|
|
342
|
+
/** The decision that covers this host (or `*`), as an event field — empty when none does. */
|
|
343
|
+
consentRef(run, host) {
|
|
344
|
+
if (host === "*")
|
|
345
|
+
return run.consentIds.has("*") ? { consentId: run.consentIds.get("*") } : {};
|
|
346
|
+
let best = null;
|
|
347
|
+
for (const [d, id] of run.consentIds)
|
|
348
|
+
if (d !== "*" && domainWithin(host, d) && (!best || d.length > best[0].length))
|
|
349
|
+
best = [d, id];
|
|
350
|
+
return best ? { consentId: best[1] } : {};
|
|
351
|
+
}
|
|
352
|
+
get(runId) {
|
|
353
|
+
const run = this.runs.get(runId);
|
|
354
|
+
if (!run)
|
|
355
|
+
throw new RunnerInputError(`No local browser run ${runId} on this runner (it may have restarted)`, 404);
|
|
356
|
+
return run;
|
|
357
|
+
}
|
|
358
|
+
kill(run) {
|
|
359
|
+
const child = run.child;
|
|
360
|
+
if (!child || child.exitCode !== null)
|
|
361
|
+
return;
|
|
362
|
+
child.kill("SIGTERM");
|
|
363
|
+
setTimeout(() => {
|
|
364
|
+
if (child.exitCode === null)
|
|
365
|
+
child.kill("SIGKILL");
|
|
366
|
+
}, 5_000).unref?.();
|
|
367
|
+
}
|
|
368
|
+
/** The CLI exited: assemble the result from what the bridge recorded and what the CLI said. */
|
|
369
|
+
finish(run, code) {
|
|
370
|
+
if (run.state === "ended")
|
|
371
|
+
return;
|
|
372
|
+
this.end(run, this.outcomeOf(run, code));
|
|
373
|
+
}
|
|
374
|
+
outcomeOf(run, code) {
|
|
375
|
+
const e = run.envelope;
|
|
376
|
+
const bridge = run.bridge;
|
|
377
|
+
const summary = bridge?.summary ?? finalText(e.engine, run.output);
|
|
378
|
+
const found = (bridge?.findings.length ?? 0) > 0;
|
|
379
|
+
if (run.cancelled)
|
|
380
|
+
return { outcome: "failed", error: "Cancelled by the owner" };
|
|
381
|
+
if (missingLogin(e.engine, run.output.join("\n")) && !found)
|
|
382
|
+
return { outcome: "failed", error: signInHelp(e.engine), engineAuth: "missing_login" };
|
|
383
|
+
const limit = `Stopped at the ${e.limits.maxMinutes}-minute limit.`;
|
|
384
|
+
if (run.timedOut)
|
|
385
|
+
return { outcome: found ? "completed" : "failed", summary: summary || limit, error: limit };
|
|
386
|
+
if (code !== 0 && !bridge?.summary) {
|
|
387
|
+
const tail = run.output.slice(-5).join("\n").slice(-800);
|
|
388
|
+
return { outcome: "failed", summary, error: `The ${e.engine} CLI exited with code ${code}${tail ? `: ${tail}` : ""}` };
|
|
389
|
+
}
|
|
390
|
+
return { outcome: "completed", summary };
|
|
391
|
+
}
|
|
392
|
+
end(run, r) {
|
|
393
|
+
if (run.state === "ended")
|
|
394
|
+
return;
|
|
395
|
+
if (run.state === "paused")
|
|
396
|
+
this.release(run, "stopped");
|
|
397
|
+
if (run.timer)
|
|
398
|
+
clearInterval(run.timer);
|
|
399
|
+
run.state = "ended";
|
|
400
|
+
run.endedAt = this.now();
|
|
401
|
+
run.result = {
|
|
402
|
+
runId: run.envelope.runId,
|
|
403
|
+
outcome: r.outcome,
|
|
404
|
+
findings: run.bridge?.findings ?? [],
|
|
405
|
+
sourceFailures: run.bridge?.sourceFailures ?? [],
|
|
406
|
+
summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
|
|
407
|
+
traceId: run.envelope.runId,
|
|
408
|
+
engineAuth: r.engineAuth ?? run.engineAuth,
|
|
409
|
+
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
410
|
+
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
411
|
+
};
|
|
412
|
+
if (run.child && run.child.exitCode === null)
|
|
413
|
+
this.kill(run);
|
|
414
|
+
void run.browser?.stop().catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
/** Drop ended runs past retention, with their folders. Custom workspaces are the owner's and are never deleted. */
|
|
417
|
+
sweep() {
|
|
418
|
+
const cutoff = this.now() - this.retentionMs;
|
|
419
|
+
for (const [id, run] of this.runs) {
|
|
420
|
+
if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
|
|
421
|
+
rmSync(run.dir, { recursive: true, force: true });
|
|
422
|
+
this.runs.delete(id);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Folders left by a previous runner process (its runs are gone from memory).
|
|
426
|
+
if (!existsSync(this.root))
|
|
427
|
+
return;
|
|
428
|
+
for (const instance of readdirSync(this.root)) {
|
|
429
|
+
const instDir = join(this.root, instance);
|
|
430
|
+
for (const runId of readdirSync(instDir)) {
|
|
431
|
+
if (this.runs.has(runId))
|
|
432
|
+
continue;
|
|
433
|
+
const dir = join(instDir, runId);
|
|
434
|
+
if (statSync(dir).mtimeMs < cutoff)
|
|
435
|
+
rmSync(dir, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Runner shutdown: stop every engine. Their runs end failed — the process that held them is going. */
|
|
440
|
+
closeAll() {
|
|
441
|
+
for (const run of this.runs.values()) {
|
|
442
|
+
if (run.state !== "ended") {
|
|
443
|
+
run.cancelled = true;
|
|
444
|
+
this.release(run, "stopped");
|
|
445
|
+
this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -12,6 +12,8 @@ import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
|
12
12
|
import { resolveHandoffStatus } from "./handoff-status.js";
|
|
13
13
|
import { RunnerStore } from "./store.js";
|
|
14
14
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
15
|
+
import { LocalBrowserRuntime } from "./local-browser/runtime.js";
|
|
16
|
+
import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
|
|
15
17
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
16
18
|
/** True for a plain object. */
|
|
17
19
|
function isRecord(value) {
|
|
@@ -73,6 +75,10 @@ export class LocalRunner {
|
|
|
73
75
|
store;
|
|
74
76
|
/** Local tmux coding sessions (the second runtime — AgentCoder port). */
|
|
75
77
|
coding;
|
|
78
|
+
/** Local CLI browser research (#944): a signed-in Codex / Claude Code CLI researching through the policy bridge. */
|
|
79
|
+
localBrowser;
|
|
80
|
+
/** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
|
|
81
|
+
selfUrl = null;
|
|
76
82
|
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
77
83
|
takeovers = new Map();
|
|
78
84
|
constructor(config) {
|
|
@@ -80,6 +86,19 @@ export class LocalRunner {
|
|
|
80
86
|
mkdirSync(config.dataDir, { recursive: true });
|
|
81
87
|
this.store = new RunnerStore(config.dataDir);
|
|
82
88
|
this.coding = new CodingRuntime(join(config.dataDir, "repos"));
|
|
89
|
+
this.localBrowser = new LocalBrowserRuntime({
|
|
90
|
+
dataDir: config.dataDir,
|
|
91
|
+
selfUrl: () => this.selfUrl,
|
|
92
|
+
// `default` is the runner's own signed-in browser, shared and never closed by a run;
|
|
93
|
+
// `isolated` is a fresh profile inside the run's folder, removed with it.
|
|
94
|
+
browserFor: async (profile, runDir) => {
|
|
95
|
+
if (profile === "default")
|
|
96
|
+
return { tools: await this.getMcp(), stop: async () => undefined };
|
|
97
|
+
const mcp = new McpRuntime();
|
|
98
|
+
await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
|
|
99
|
+
return { tools: mcp, stop: () => mcp.stop() };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
83
102
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
84
103
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
85
104
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -94,7 +113,7 @@ export class LocalRunner {
|
|
|
94
113
|
runtimePlane: "pags",
|
|
95
114
|
runnerRole: "tool-executor",
|
|
96
115
|
capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
|
|
97
|
-
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", ...CodingRuntime.taskTypes()],
|
|
116
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, ...CodingRuntime.taskTypes()],
|
|
98
117
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
99
118
|
};
|
|
100
119
|
}
|
|
@@ -230,6 +249,7 @@ export class LocalRunner {
|
|
|
230
249
|
* what orphans the browser, so an unswallowed error here MADE the leak.
|
|
231
250
|
*/
|
|
232
251
|
async close() {
|
|
252
|
+
this.localBrowser.closeAll();
|
|
233
253
|
try {
|
|
234
254
|
this.coding.closeAll();
|
|
235
255
|
}
|