@proagentstore/cli 0.4.71 → 0.4.72

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.
@@ -7,7 +7,11 @@ export class HumanHandoffError extends Error {
7
7
  this.name = "HumanHandoffError";
8
8
  }
9
9
  }
10
- /** A bad client request to the runner (maps to HTTP 400). */
10
+ /** A bad client request to the runner (HTTP 400 by default; 404/409 where the request names a missing or busy thing). */
11
11
  export class RunnerInputError extends Error {
12
- status = 400;
12
+ status;
13
+ constructor(message, status = 400) {
14
+ super(message);
15
+ this.status = status;
16
+ }
13
17
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The research CLI's only MCP server (#944): a stdio forwarder to the runner.
3
+ *
4
+ * Codex and Claude Code launch MCP servers as their own child processes, so the policy cannot live
5
+ * in this process — it would be a second copy of the run's state. This file therefore holds NO
6
+ * policy: it relays `tools/list` and `tools/call` to the runner's `POST /local-browser/bridge`,
7
+ * where `BrowserBridge` decides everything. A run-scoped token authorizes it, and the runner
8
+ * accepts that token for that one run and that one path only.
9
+ *
10
+ * `node:http` rather than `fetch`: a consent or captcha pause holds a call open for minutes, and
11
+ * fetch's undici agent abandons a response that has not started within five.
12
+ */
13
+ import { request } from "node:http";
14
+ import { fileURLToPath } from "node:url";
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ /** Env var names the runtime sets on this process. */
19
+ export const BRIDGE_ENV = { url: "PAGS_BRIDGE_URL", runId: "PAGS_BRIDGE_RUN", token: "PAGS_BRIDGE_TOKEN" };
20
+ /** POST to the runner's bridge path and return the parsed JSON body. */
21
+ export const postToRunner = (endpoint, body) => new Promise((resolve, reject) => {
22
+ const payload = JSON.stringify({ ...body, runId: endpoint.runId });
23
+ const req = request(`${endpoint.url.replace(/\/$/, "")}/local-browser/bridge`, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), "X-Pags-Bridge-Token": endpoint.token } }, (res) => {
24
+ const chunks = [];
25
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
26
+ res.on("end", () => {
27
+ try {
28
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
29
+ if ((res.statusCode ?? 500) >= 400)
30
+ reject(new Error(typeof parsed.error === "string" ? parsed.error : `runner answered ${res.statusCode}`));
31
+ else
32
+ resolve(parsed);
33
+ }
34
+ catch (err) {
35
+ reject(err);
36
+ }
37
+ });
38
+ });
39
+ req.on("error", reject);
40
+ req.end(payload);
41
+ });
42
+ /** The MCP server, wired to a runner endpoint. Exported so tests drive it without a real stdio. */
43
+ export function createBridgeServer(endpoint, post = postToRunner) {
44
+ const server = new Server({ name: "pags_browser", version: "1.0.0" }, { capabilities: { tools: {} } });
45
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
46
+ const res = (await post(endpoint, { op: "list" }));
47
+ return { tools: (res.tools ?? []) };
48
+ });
49
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
50
+ try {
51
+ return (await post(endpoint, { op: "call", name: req.params.name, args: req.params.arguments ?? {} }));
52
+ }
53
+ catch (err) {
54
+ // The runner refusing or going away is a tool error the CLI can read, not a crash.
55
+ return { content: [{ type: "text", text: `The research browser is unavailable: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
56
+ }
57
+ });
58
+ return server;
59
+ }
60
+ async function main() {
61
+ const url = process.env[BRIDGE_ENV.url];
62
+ const runId = process.env[BRIDGE_ENV.runId];
63
+ const token = process.env[BRIDGE_ENV.token];
64
+ if (!url || !runId || !token) {
65
+ process.stderr.write("pags_browser: missing bridge environment; this server is started by the PAGS runner only\n");
66
+ process.exit(2);
67
+ }
68
+ await createBridgeServer({ url, runId, token }).connect(new StdioServerTransport());
69
+ }
70
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
71
+ main().catch((err) => {
72
+ process.stderr.write(`pags_browser: ${err instanceof Error ? err.message : String(err)}\n`);
73
+ process.exit(1);
74
+ });
75
+ }
@@ -0,0 +1,299 @@
1
+ import { LOCAL_BROWSER_SOURCE_FAILURE_REASONS, redactDetail } from "./contract.js";
2
+ /** The browser tools a research run may use. Everything else from the browser is not even listed. */
3
+ const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for", "browser_click"]);
4
+ /** Refused BY NAME with the reason, so a CLI that guesses one is told why rather than "unknown tool". */
5
+ const WRITE_TOOLS = new Set([
6
+ "browser_type",
7
+ "browser_fill_form",
8
+ "browser_select_option",
9
+ "browser_file_upload",
10
+ "browser_evaluate",
11
+ "browser_run_code",
12
+ "browser_press_key",
13
+ "browser_drag",
14
+ "browser_hover",
15
+ "browser_handle_dialog",
16
+ "browser_install",
17
+ ]);
18
+ /** Roles a click may target in research mode: moving between pages, never acting on one. */
19
+ const CLICKABLE_ROLES = new Set(["link", "tab", "menuitem", "treeitem", "option"]);
20
+ const PAGINATION_NAME = /^(next|previous|prev|more|load more|show more|see more|view more|older|newer|page \d+|\d+|›|»|‹|«)( page| results| jobs)?$/i;
21
+ const RESEARCH_TOOLS = [
22
+ {
23
+ name: "record_finding",
24
+ description: "Record one finding from a page you opened in this run. url must be on a site you navigated to; evidence is the exact text on that page the finding comes from.",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ title: { type: "string" },
29
+ url: { type: "string" },
30
+ evidence: { type: "string" },
31
+ fields: { type: "object", additionalProperties: { type: ["string", "number", "boolean", "null"] } },
32
+ },
33
+ required: ["title", "url", "evidence"],
34
+ },
35
+ },
36
+ {
37
+ name: "report_source_failure",
38
+ description: `Record a source you could not read, and why: ${LOCAL_BROWSER_SOURCE_FAILURE_REASONS.join(", ")}.`,
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: { url: { type: "string" }, reason: { type: "string", enum: [...LOCAL_BROWSER_SOURCE_FAILURE_REASONS] }, detail: { type: "string" } },
42
+ required: ["url", "reason"],
43
+ },
44
+ },
45
+ {
46
+ name: "finish_research",
47
+ description: "End the research with a short summary of what you found and what you could not reach. Call it once, last.",
48
+ inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
49
+ },
50
+ ];
51
+ /** Runs inside the page, through the browser's own evaluate tool — privileged, never offered to the CLI. */
52
+ const INSPECT_PAGE = `() => {
53
+ const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
54
+ const has = (s) => !!document.querySelector(s);
55
+ const captcha = has('iframe[src*="hcaptcha.com"], .h-captcha, iframe[src*="challenges.cloudflare.com"], .cf-turnstile, iframe[src*="arkoselabs"], .geetest_holder')
56
+ || /confirm (that )?you('?re| are) not a robot|i'?m not a robot|verify (that )?you('?re| are) (a )?human|checking (if the site connection is secure|your browser)/.test(text);
57
+ const login = has('input[type=password]');
58
+ const paywall = /subscribe to (continue|read)|you('ve| have) reached your (free )?(article )?limit|sign in to (continue|read)/.test(text);
59
+ return { url: location.href, title: document.title.slice(0, 200), captcha, login, paywall };
60
+ }`;
61
+ /** A bare lowercase hostname, or null — the same rule the API applies to the lists it sends. */
62
+ export function hostOf(raw) {
63
+ try {
64
+ const u = new URL(raw);
65
+ if (u.protocol !== "https:" && u.protocol !== "http:")
66
+ return null;
67
+ return u.hostname.toLowerCase().replace(/\.$/, "");
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ export function domainWithin(host, base) {
74
+ return host === base || host.endsWith(`.${base}`);
75
+ }
76
+ const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
77
+ const textOf = (r) => (r.content ?? []).map((c) => c.text ?? "").join("\n");
78
+ /** Pull the JSON value out of an evaluate result (`### Result\n<json>\n### …`). */
79
+ export function evaluateResult(raw) {
80
+ const i = raw.indexOf("### Result");
81
+ const after = (i >= 0 ? raw.slice(i + "### Result".length) : raw).trim();
82
+ const end = after.indexOf("\n###");
83
+ const block = (end >= 0 ? after.slice(0, end) : after).trim().replace(/^```[a-z]*\n?|```$/g, "").trim();
84
+ try {
85
+ return JSON.parse(block);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /** The role and accessible name of a snapshot line holding this ref, e.g. `- link "Next" [ref=e12]`. */
92
+ export function refRole(snapshot, ref) {
93
+ for (const line of snapshot.split("\n")) {
94
+ if (!line.includes(`[ref=${ref}]`))
95
+ continue;
96
+ const m = line.match(/^\s*-\s*([a-z]+)(?:\s+"([^"]*)")?/i);
97
+ return m ? { role: m[1].toLowerCase(), name: m[2] ?? "" } : null;
98
+ }
99
+ return null;
100
+ }
101
+ export class BrowserBridge {
102
+ browser;
103
+ host;
104
+ actions = 0;
105
+ pages = 0;
106
+ lastSnapshot = "";
107
+ visited = new Set();
108
+ findings = [];
109
+ sourceFailures = [];
110
+ summary = null;
111
+ constructor(browser, host) {
112
+ this.browser = browser;
113
+ this.host = host;
114
+ }
115
+ /** What the CLI sees: the read-only browser tools that exist, plus the research tools. */
116
+ async listTools() {
117
+ const tools = (await this.browser.listTools()).filter((t) => READ_TOOLS.has(t.name));
118
+ return [
119
+ ...tools.map((t) => (t.name === "browser_click" ? { ...t, description: "Click a link, tab or pagination button (Next, More, a page number) from the latest snapshot. Nothing else can be clicked in research mode." } : t)),
120
+ ...RESEARCH_TOOLS,
121
+ ];
122
+ }
123
+ async callTool(name, args = {}) {
124
+ if (name === "record_finding")
125
+ return this.recordFinding(args);
126
+ if (name === "report_source_failure")
127
+ return this.reportFailure(args);
128
+ if (name === "finish_research") {
129
+ this.summary = typeof args.summary === "string" ? args.summary.slice(0, 4000) : "";
130
+ return text("Recorded. The research is finished; stop now.");
131
+ }
132
+ if (WRITE_TOOLS.has(name))
133
+ return this.refuse(name, `${name} is not available: this run is research only — no typing, forms, uploads, key presses or scripts.`);
134
+ if (!READ_TOOLS.has(name))
135
+ return this.refuse(name, `${name} is not available in research mode.`);
136
+ if (this.host.overTime())
137
+ return this.refuse(name, "The run's time limit is reached. Call finish_research now.");
138
+ if (++this.actions > this.host.limits.maxActions)
139
+ return this.refuse(name, `The run's limit of ${this.host.limits.maxActions} browser actions is reached. Call finish_research now.`);
140
+ if (name === "browser_navigate")
141
+ return this.navigate(String(args.url ?? ""));
142
+ if (name === "browser_click")
143
+ return this.click(args);
144
+ const res = await this.browser.callTool(name, args);
145
+ if (name === "browser_snapshot") {
146
+ this.lastSnapshot = textOf(res);
147
+ this.host.emit({ type: "browser.snapshot", ...this.currentHost() });
148
+ }
149
+ if (name === "browser_navigate_back") {
150
+ const state = await this.inspect();
151
+ if (state)
152
+ this.noteNavigation(state);
153
+ }
154
+ return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
155
+ }
156
+ currentHost() {
157
+ const last = [...this.visited].pop();
158
+ return last ? { domain: last } : {};
159
+ }
160
+ refuse(tool, reason) {
161
+ this.host.emit({ type: "policy.decision", detail: { tool, decision: "refused", reason } });
162
+ return text(reason, true);
163
+ }
164
+ /** May the run be on this host — asking the owner, and waiting, when it is a new one. */
165
+ async admit(host, url) {
166
+ if (this.host.isDenied(host))
167
+ return `${host} is on this run's deny list.`;
168
+ if (this.host.isPermitted(host))
169
+ return null;
170
+ if (this.host.allowListOnly())
171
+ return `${host} is not on this run's list of allowed sites.`;
172
+ this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
173
+ const outcome = await this.host.pause("consent_required", { domain: host });
174
+ if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
175
+ this.host.emit({ type: "policy.decision", domain: host, detail: { decision: "consented" } });
176
+ return null;
177
+ }
178
+ return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
179
+ }
180
+ async navigate(url) {
181
+ const host = hostOf(url);
182
+ if (!host)
183
+ return this.refuse("browser_navigate", "Only http(s) URLs can be opened.");
184
+ if (this.pages >= this.host.limits.maxPages)
185
+ return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
186
+ const refusal = await this.admit(host, url);
187
+ if (refusal)
188
+ return this.refuse("browser_navigate", refusal);
189
+ const res = await this.browser.callTool("browser_navigate", { url });
190
+ if (res.isError)
191
+ return { content: [{ type: "text", text: textOf(res) }], isError: true };
192
+ // A redirect is a second navigation the CLI did not choose. Following one within the same
193
+ // site (www., a jobs. subdomain) is fine; landing somewhere unrelated and unpermitted is not.
194
+ const state = await this.inspect();
195
+ const landedOn = state ? hostOf(state.url) : null;
196
+ const related = (a, b) => domainWithin(a, b) || domainWithin(b, a);
197
+ if (landedOn && !related(landedOn, host) && (this.host.isDenied(landedOn) || !this.host.isPermitted(landedOn))) {
198
+ await this.browser.callTool("browser_navigate_back", {}).catch(() => undefined);
199
+ return this.refuse("browser_navigate", `${host} redirected to ${landedOn}, a site this run has not been allowed on. Open ${landedOn} with browser_navigate to ask the owner, or skip it.`);
200
+ }
201
+ return this.landed(textOf(res), state);
202
+ }
203
+ async click(args) {
204
+ const ref = typeof args.target === "string" ? args.target : typeof args.ref === "string" ? args.ref : "";
205
+ const target = ref ? refRole(this.lastSnapshot, ref) : null;
206
+ const allowed = target && (CLICKABLE_ROLES.has(target.role) || (target.role === "button" && PAGINATION_NAME.test(target.name.trim())));
207
+ if (!allowed)
208
+ return this.refuse("browser_click", "In research mode only links, tabs and pagination buttons (Next, More, a page number) from the latest browser_snapshot can be clicked.");
209
+ if (this.pages >= this.host.limits.maxPages)
210
+ return this.refuse("browser_click", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
211
+ const res = await this.browser.callTool("browser_click", { element: typeof args.element === "string" ? args.element : target.name, target: ref });
212
+ if (res.isError)
213
+ return { content: [{ type: "text", text: textOf(res) }], isError: true };
214
+ const state = await this.inspect();
215
+ const host = state ? hostOf(state.url) : null;
216
+ // A click that lands on a site this run may not be on is undone, and the CLI is told to
217
+ // navigate there instead — which is the path that asks the owner.
218
+ if (host && !this.visited.has(host) && (this.host.isDenied(host) || !this.host.isPermitted(host))) {
219
+ await this.browser.callTool("browser_navigate_back", {}).catch(() => undefined);
220
+ return this.refuse("browser_click", `That link leads to ${host}, a site this run has not been allowed on. Open it with browser_navigate to ask the owner.`);
221
+ }
222
+ return this.landed(textOf(res), state);
223
+ }
224
+ /** After a page change: count it, trace it, and stop for a person when the page needs one. */
225
+ async landed(toolText, known) {
226
+ const state = known ?? (await this.inspect());
227
+ if (!state)
228
+ return text(toolText);
229
+ this.pages++;
230
+ this.noteNavigation(state);
231
+ const host = hostOf(state.url) ?? "";
232
+ const blocker = state.captcha ? "captcha" : state.login ? "login_required" : null;
233
+ if (blocker) {
234
+ this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: blocker } });
235
+ const outcome = await this.host.pause(blocker, { domain: host });
236
+ if (outcome !== "resumed")
237
+ return text(`${host} needs a person (${blocker === "captcha" ? "a captcha" : "a sign-in"}) and nobody resolved it. Record it with report_source_failure (${blocker}) and move on.`, true);
238
+ const after = await this.inspect();
239
+ if (after && (after.captcha || after.login))
240
+ return text(`${host} still shows ${blocker === "captcha" ? "a captcha" : "a sign-in"}. Record it with report_source_failure (${blocker}) and move on.`, true);
241
+ return text(`Resumed after the owner handled ${host}. Take a browser_snapshot to read the page.`);
242
+ }
243
+ if (state.paywall) {
244
+ this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: "paywall" } });
245
+ return text(`${host} is behind a paywall. Do not try to get around it: record it with report_source_failure (paywall) and move on.`, true);
246
+ }
247
+ return text(toolText);
248
+ }
249
+ noteNavigation(state) {
250
+ const host = hostOf(state.url);
251
+ if (!host)
252
+ return;
253
+ this.visited.add(host);
254
+ this.host.emit({ type: "browser.navigated", url: state.url, domain: host, detail: redactDetail({ title: state.title }) });
255
+ }
256
+ async inspect() {
257
+ const res = await this.browser.callTool("browser_evaluate", { function: INSPECT_PAGE }).catch(() => null);
258
+ const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
259
+ if (!v || typeof v.url !== "string")
260
+ return null;
261
+ return { url: v.url, title: typeof v.title === "string" ? v.title : "", captcha: !!v.captcha, login: !!v.login, paywall: !!v.paywall };
262
+ }
263
+ recordFinding(args) {
264
+ const title = typeof args.title === "string" ? args.title.trim() : "";
265
+ const url = typeof args.url === "string" ? args.url.trim() : "";
266
+ const evidence = typeof args.evidence === "string" ? args.evidence.trim() : "";
267
+ const host = hostOf(url);
268
+ if (!title || !evidence || !host)
269
+ return text("A finding needs a title, an http(s) url and evidence text.", true);
270
+ if (![...this.visited].some((v) => domainWithin(host, v) || domainWithin(v, host))) {
271
+ return text(`${host} was not opened in this run. A finding must cite a page you navigated to.`, true);
272
+ }
273
+ if (this.findings.some((f) => f.url === url && f.title === title))
274
+ return text("Already recorded.");
275
+ if (this.findings.length >= 200)
276
+ return text("This run holds at most 200 findings. Call finish_research.", true);
277
+ const fields = {};
278
+ const raw = args.fields && typeof args.fields === "object" && !Array.isArray(args.fields) ? args.fields : {};
279
+ for (const [k, v] of Object.entries(redactDetail(raw) ?? {})) {
280
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean" || v === null)
281
+ fields[k] = v;
282
+ }
283
+ const finding = { title: title.slice(0, 500), url: url.slice(0, 2000), evidence: evidence.slice(0, 4000), fields };
284
+ this.findings.push(finding);
285
+ this.host.emit({ type: "finding.parsed", url: finding.url, domain: host, detail: { title: finding.title } });
286
+ return text(`Recorded finding ${this.findings.length}.`);
287
+ }
288
+ reportFailure(args) {
289
+ const url = typeof args.url === "string" ? args.url.trim() : "";
290
+ const reason = LOCAL_BROWSER_SOURCE_FAILURE_REASONS.includes(args.reason) ? args.reason : null;
291
+ if (!url || !reason)
292
+ return text(`A source failure needs a url and a reason: ${LOCAL_BROWSER_SOURCE_FAILURE_REASONS.join(", ")}.`, true);
293
+ if (this.sourceFailures.length >= 200)
294
+ return text("This run holds at most 200 source failures.", true);
295
+ const detail = typeof args.detail === "string" ? args.detail.slice(0, 1000) : undefined;
296
+ this.sourceFailures.push(detail ? { url: url.slice(0, 2000), reason, detail } : { url: url.slice(0, 2000), reason });
297
+ return text("Recorded.");
298
+ }
299
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Local CLI browser research: the wire contract between PAGS and the runner (#943, #945, #944).
3
+ *
4
+ * PAGS supervises a general agent; a Codex or Claude Code CLI signed in on the owner's machine
5
+ * drives the runner's browser. This file is everything that crosses the relay: the task envelope
6
+ * PAGS sends, the events the runner reports while it works, and the result envelope it returns.
7
+ *
8
+ * ── Why it is a file pair, not a package import
9
+ *
10
+ * The Worker and the runner each typecheck with `rootDir: "src"`, so neither can import the
11
+ * other's source, and the runner ships inside the published CLI. The repo's answer to that is to
12
+ * vendor (root CLAUDE.md, "Vendor, don't depend"). This file is therefore kept BYTE-IDENTICAL at
13
+ *
14
+ * workers/api/src/lib/local-browser/contract.ts
15
+ * packages/browser-runner/src/local-browser/contract.ts
16
+ *
17
+ * and `local-browser-contract-copy.test.ts` fails the moment the two differ. Edit both together.
18
+ * It must stay dependency-free (no imports) for the same reason.
19
+ */
20
+ /**
21
+ * The runner task type, and the runner endpoints PAGS calls.
22
+ *
23
+ * PULL, not push (#944): the relay carries only cloud→runner commands and their replies, and the
24
+ * runner process holds no API token. So the runner keeps each run's events and result, and PAGS
25
+ * reads them with `status` — when a run is read and from the per-minute cron.
26
+ */
27
+ export const LOCAL_BROWSER_TASK_TYPE = "local_browser.research";
28
+ export const LOCAL_BROWSER_RUN_PATH = "/local-browser/run";
29
+ export const LOCAL_BROWSER_CANCEL_PATH = "/local-browser/cancel";
30
+ export const LOCAL_BROWSER_STATUS_PATH = "/local-browser/status";
31
+ export const LOCAL_BROWSER_RESUME_PATH = "/local-browser/resume";
32
+ export const LOCAL_BROWSER_ENGINES = ["claude", "codex"];
33
+ export const LOCAL_BROWSER_AUTH_MODES = ["subscription", "machine", "api-key"];
34
+ export const LOCAL_BROWSER_MODES = ["research_only"];
35
+ export const LOCAL_BROWSER_PAUSE_REASONS = ["login_required", "captcha", "consent_required", "access_blocked", "paywall", "write_affordance"];
36
+ export const LOCAL_BROWSER_EVENT_TYPES = [
37
+ "engine.started",
38
+ "engine.auth_checked",
39
+ "engine.ended",
40
+ "browser.navigated",
41
+ "browser.snapshot",
42
+ "browser.blocked",
43
+ "policy.decision",
44
+ "consent.requested",
45
+ "finding.parsed",
46
+ "run.paused",
47
+ "run.resumed",
48
+ "note",
49
+ ];
50
+ export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended"];
51
+ export const LOCAL_BROWSER_ENGINE_AUTHS = ["subscription", "machine-login", "api-key", "missing_login", "unknown"];
52
+ export const LOCAL_BROWSER_SOURCE_FAILURE_REASONS = ["login_required", "captcha", "paywall", "access_denied", "robots", "timeout", "error"];
53
+ export const LOCAL_BROWSER_CAPS = { findings: 200, sourceFailures: 200, text: 4000, fieldCount: 40 };
54
+ const SENSITIVE_KEY = /cookie|password|passwd|secret|token|authorization|api[_-]?key|otp|form[_-]?values?|credential/i;
55
+ /**
56
+ * Drop sensitive keys and bound sizes. A trace is for a supervisor to read, so nothing in it may
57
+ * carry a cookie, a password, a form value or a key — whatever the runner sent.
58
+ */
59
+ export function redactDetail(detail, depth = 0) {
60
+ if (!detail || typeof detail !== "object" || Array.isArray(detail) || depth > 3)
61
+ return undefined;
62
+ const out = {};
63
+ for (const [k, v] of Object.entries(detail).slice(0, 40)) {
64
+ if (SENSITIVE_KEY.test(k))
65
+ continue;
66
+ if (typeof v === "string")
67
+ out[k] = v.slice(0, 1000);
68
+ else if (typeof v === "number" || typeof v === "boolean" || v === null)
69
+ out[k] = v;
70
+ else if (Array.isArray(v))
71
+ out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? x.slice(0, 300) : typeof x === "number" || typeof x === "boolean" ? x : null));
72
+ else if (typeof v === "object") {
73
+ const nested = redactDetail(v, depth + 1);
74
+ if (nested)
75
+ out[k] = nested;
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
81
+ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
82
+ const oneOf = (list, v) => (list.includes(v) ? v : null);
83
+ /** A validated event, or null. */
84
+ export function parseLocalBrowserEvent(raw) {
85
+ if (!raw || typeof raw !== "object")
86
+ return null;
87
+ const o = raw;
88
+ const type = oneOf(LOCAL_BROWSER_EVENT_TYPES, o.type);
89
+ const at = str(o.at, 40);
90
+ if (!type || !at || Number.isNaN(Date.parse(at)))
91
+ return null;
92
+ const event = { type, at };
93
+ const url = str(o.url, 2000);
94
+ if (url && isHttpUrl(url))
95
+ event.url = url;
96
+ const domain = str(o.domain, 253);
97
+ if (domain)
98
+ event.domain = domain.toLowerCase();
99
+ const pauseReason = oneOf(LOCAL_BROWSER_PAUSE_REASONS, o.pauseReason);
100
+ if (type === "run.paused" && !pauseReason)
101
+ return null;
102
+ if (pauseReason)
103
+ event.pauseReason = pauseReason;
104
+ const consentId = str(o.consentId, 100);
105
+ if (consentId)
106
+ event.consentId = consentId;
107
+ const detail = redactDetail(o.detail);
108
+ if (detail && Object.keys(detail).length)
109
+ event.detail = detail;
110
+ return event;
111
+ }
112
+ /**
113
+ * A validated result envelope, or the reason it is not one.
114
+ *
115
+ * Wrapped as `{ result }` on purpose: a FAILED envelope carries its own `error` field, so returning
116
+ * the envelope bare made `"error" in parsed` true for every failed run and rejected it as invalid.
117
+ */
118
+ export function parseLocalBrowserResult(raw) {
119
+ if (!raw || typeof raw !== "object")
120
+ return { error: "result must be an object" };
121
+ const o = raw;
122
+ const runId = str(o.runId, 100);
123
+ if (!runId)
124
+ return { error: "runId is required" };
125
+ const outcome = o.outcome === "completed" || o.outcome === "failed" ? o.outcome : null;
126
+ if (!outcome)
127
+ return { error: "outcome must be completed or failed" };
128
+ const traceId = str(o.traceId, 100);
129
+ if (!traceId)
130
+ return { error: "traceId is required" };
131
+ const engineAuth = oneOf(LOCAL_BROWSER_ENGINE_AUTHS, o.engineAuth);
132
+ if (!engineAuth)
133
+ return { error: `engineAuth must be one of ${LOCAL_BROWSER_ENGINE_AUTHS.join(", ")}` };
134
+ if (!Array.isArray(o.findings) || !Array.isArray(o.sourceFailures))
135
+ return { error: "findings and sourceFailures must be arrays" };
136
+ if (o.findings.length > LOCAL_BROWSER_CAPS.findings)
137
+ return { error: `at most ${LOCAL_BROWSER_CAPS.findings} findings` };
138
+ if (o.sourceFailures.length > LOCAL_BROWSER_CAPS.sourceFailures)
139
+ return { error: `at most ${LOCAL_BROWSER_CAPS.sourceFailures} source failures` };
140
+ const findings = [];
141
+ for (const [i, f] of o.findings.entries()) {
142
+ const r = (f && typeof f === "object" ? f : {});
143
+ const title = str(r.title, 500);
144
+ const url = str(r.url, 2000);
145
+ const evidence = str(r.evidence, LOCAL_BROWSER_CAPS.text);
146
+ if (!title || !url || !isHttpUrl(url) || !evidence)
147
+ return { error: `findings[${i}] needs title, an http(s) url and evidence` };
148
+ const fields = {};
149
+ const rawFields = r.fields && typeof r.fields === "object" && !Array.isArray(r.fields) ? r.fields : {};
150
+ for (const [k, v] of Object.entries(rawFields).slice(0, LOCAL_BROWSER_CAPS.fieldCount)) {
151
+ if (SENSITIVE_KEY.test(k))
152
+ continue;
153
+ if (typeof v === "string")
154
+ fields[k.slice(0, 100)] = v.slice(0, LOCAL_BROWSER_CAPS.text);
155
+ else if (typeof v === "number" || typeof v === "boolean" || v === null)
156
+ fields[k.slice(0, 100)] = v;
157
+ }
158
+ findings.push({ title, url, evidence, fields });
159
+ }
160
+ const sourceFailures = [];
161
+ for (const [i, f] of o.sourceFailures.entries()) {
162
+ const r = (f && typeof f === "object" ? f : {});
163
+ const url = str(r.url, 2000);
164
+ const reason = oneOf(LOCAL_BROWSER_SOURCE_FAILURE_REASONS, r.reason);
165
+ if (!url || !reason)
166
+ return { error: `sourceFailures[${i}] needs url and a known reason` };
167
+ const detail = str(r.detail, 1000);
168
+ sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
169
+ }
170
+ const out = { runId, outcome, findings, sourceFailures, summary: str(o.summary, LOCAL_BROWSER_CAPS.text) ?? "", traceId, engineAuth };
171
+ const error = str(o.error, 1000);
172
+ if (outcome === "failed")
173
+ out.error = error ?? "The run failed without a reason.";
174
+ return { result: out };
175
+ }
@@ -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,412 @@
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, } 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: { mode: "research_only", allowDomains: strList(p.allowDomains), denyDomains: strList(p.denyDomains), consentedDomains: strList(p.consentedDomains), profileConsented: p.profileConsented === true },
80
+ limits,
81
+ resultSchema: { id: typeof schema.id === "string" ? schema.id : "findings", version: typeof schema.version === "number" ? schema.version : 1 },
82
+ };
83
+ }
84
+ /**
85
+ * A `~/…` workspace, resolved under the home directory — re-checked here against the real disk,
86
+ * because the API could only check the string. A symlink out of home is refused like `..` is.
87
+ */
88
+ export function resolveWorkspacePath(path, home) {
89
+ if (!/^~\/[^\0]+$/.test(path))
90
+ throw new RunnerInputError('The workspace folder must be written "~/…", under this machine\'s home folder.');
91
+ const realHome = realpathSync(home);
92
+ const target = resolve(realHome, path.slice(2));
93
+ if (!target.startsWith(realHome + sep))
94
+ throw new RunnerInputError("The workspace folder must be inside this machine's home folder.");
95
+ mkdirSync(target, { recursive: true });
96
+ const real = realpathSync(target);
97
+ if (!real.startsWith(realHome + sep))
98
+ throw new RunnerInputError("The workspace folder resolves outside this machine's home folder (a symlink).");
99
+ return real;
100
+ }
101
+ export class LocalBrowserRuntime {
102
+ deps;
103
+ runs = new Map();
104
+ root;
105
+ now;
106
+ spawn;
107
+ retentionMs;
108
+ constructor(deps) {
109
+ this.deps = deps;
110
+ this.root = join(deps.dataDir, "local-browser");
111
+ this.now = deps.now ?? Date.now;
112
+ this.spawn = deps.spawn ?? nodeSpawn;
113
+ this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
114
+ }
115
+ /** Start a run. A requestId already held for the instance returns that run instead. */
116
+ start(raw) {
117
+ const envelope = parseEnvelope(raw);
118
+ this.sweep();
119
+ const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
120
+ if (same)
121
+ return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
122
+ if (this.runs.has(envelope.runId))
123
+ throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
124
+ const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
125
+ if (active >= envelope.limits.maxConcurrent)
126
+ throw new RunnerInputError(`This machine is already running ${active} research run(s) for this agent (limit ${envelope.limits.maxConcurrent}).`, 409);
127
+ const selfUrl = this.deps.selfUrl();
128
+ if (!selfUrl)
129
+ throw new RunnerInputError("The runner is still starting; try again in a moment.", 409);
130
+ const dir = join(this.root, envelope.instanceId, envelope.runId);
131
+ mkdirSync(dir, { recursive: true });
132
+ const workDir = envelope.workspace.kind === "path" ? resolveWorkspacePath(envelope.workspace.path, this.deps.homeDir ?? homedir()) : join(dir, "scratch");
133
+ mkdirSync(workDir, { recursive: true });
134
+ const run = {
135
+ envelope,
136
+ dir,
137
+ workDir,
138
+ token: randomBytes(24).toString("hex"),
139
+ state: "running",
140
+ events: [],
141
+ seq: 0,
142
+ consented: new Set(envelope.policy.consentedDomains),
143
+ deny: new Set(envelope.policy.denyDomains),
144
+ profileConsented: envelope.policy.profileConsented,
145
+ activeSince: this.now(),
146
+ activeMs: 0,
147
+ waiters: [],
148
+ output: [],
149
+ engineAuth: "unknown",
150
+ cancelled: false,
151
+ timedOut: false,
152
+ };
153
+ this.runs.set(envelope.runId, run);
154
+ // Not awaited: a signed-in-profile consent pause can hold the launch for minutes, and the
155
+ // relay command that started the run must answer now. Everything after this is in `status`.
156
+ void this.launch(run, selfUrl).catch((err) => this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) }));
157
+ return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
158
+ }
159
+ async launch(run, selfUrl) {
160
+ const e = run.envelope;
161
+ // The signed-in profile is the owner's own browser — never without their say-so (#947).
162
+ if (e.browserProfile === "default" && !run.profileConsented) {
163
+ this.emit(run, { type: "consent.requested", detail: { scope: "signed_in_profile" } });
164
+ const outcome = await this.pause(run, "consent_required", { scope: "signed_in_profile" });
165
+ if (outcome !== "resumed" || !run.profileConsented)
166
+ throw new Error("The owner did not allow research in their signed-in browser profile.");
167
+ }
168
+ run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
169
+ run.bridge = new BrowserBridge(run.browser.tools, {
170
+ emit: (ev) => this.emit(run, ev),
171
+ pause: (reason, detail) => this.pause(run, reason, detail),
172
+ isDenied: (host) => [...run.deny].some((d) => domainWithin(host, d)),
173
+ isPermitted: (host) => [...e.policy.allowDomains, ...run.consented].some((d) => domainWithin(host, d)),
174
+ allowListOnly: () => e.policy.allowDomains.length > 0,
175
+ overTime: () => this.activeMs(run) >= e.limits.maxMinutes * 60_000,
176
+ limits: e.limits,
177
+ });
178
+ const bridgeScript = this.deps.bridgeScript ?? fileURLToPath(new URL("./bridge-stdio.js", import.meta.url));
179
+ const spec = buildEngineSpec({
180
+ engine: e.engine,
181
+ authMode: e.authMode,
182
+ prompt: researchPrompt(e),
183
+ bridge: { command: process.execPath, args: [bridgeScript], env: { [BRIDGE_ENV.url]: selfUrl, [BRIDGE_ENV.runId]: e.runId, [BRIDGE_ENV.token]: run.token } },
184
+ mcpConfigPath: join(run.dir, "mcp.json"),
185
+ toolTimeoutMs: e.limits.maxMinutes * 60_000,
186
+ });
187
+ if (spec.mcpConfig)
188
+ writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
189
+ run.engineAuth = observedEngineAuth(e.engine, spec.env);
190
+ this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
191
+ const child = this.spawn(spec.command, spec.args, { cwd: run.workDir, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
192
+ run.child = child;
193
+ this.emit(run, { type: "engine.started", detail: { engine: e.engine, workspace: e.workspace.kind } });
194
+ let buf = "";
195
+ const keep = (chunk) => {
196
+ buf += chunk.toString();
197
+ const lines = buf.split("\n");
198
+ buf = lines.pop() ?? "";
199
+ for (const line of lines)
200
+ if (line.trim())
201
+ run.output.push(line.slice(0, 20_000));
202
+ if (run.output.length > OUTPUT_LINES)
203
+ run.output.splice(0, run.output.length - OUTPUT_LINES);
204
+ };
205
+ child.stdout?.on("data", keep);
206
+ child.stderr?.on("data", keep);
207
+ child.on("error", (err) => {
208
+ const missing = err.code === "ENOENT";
209
+ 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 });
210
+ });
211
+ child.on("close", (code) => {
212
+ if (buf.trim())
213
+ run.output.push(buf);
214
+ this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
215
+ this.finish(run, code ?? -1);
216
+ });
217
+ run.timer = setInterval(() => {
218
+ if (run.state !== "ended" && this.activeMs(run) >= e.limits.maxMinutes * 60_000 + 60_000) {
219
+ // The bridge already refuses past the limit; this is the backstop for a CLI that keeps
220
+ // thinking without calling a tool.
221
+ run.timedOut = true;
222
+ this.kill(run);
223
+ }
224
+ }, 5_000);
225
+ run.timer.unref?.();
226
+ }
227
+ activeMs(run) {
228
+ return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
229
+ }
230
+ emit(run, ev) {
231
+ if (run.events.length >= MAX_EVENTS)
232
+ return;
233
+ const { detail: raw, ...rest } = ev;
234
+ const detail = redactDetail(raw);
235
+ run.events.push({ ...rest, ...(detail && Object.keys(detail).length ? { detail } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
236
+ }
237
+ pause(run, reason, detail) {
238
+ if (run.state === "ended" || run.cancelled)
239
+ return Promise.resolve("stopped");
240
+ if (run.state === "running") {
241
+ run.activeMs += this.now() - run.activeSince;
242
+ run.state = "paused";
243
+ run.pauseReason = reason;
244
+ this.emit(run, { type: "run.paused", pauseReason: reason, detail });
245
+ }
246
+ return new Promise((resolveWait) => run.waiters.push(resolveWait));
247
+ }
248
+ release(run, outcome) {
249
+ if (run.state === "paused") {
250
+ run.state = "running";
251
+ run.pauseReason = undefined;
252
+ run.activeSince = this.now();
253
+ if (outcome === "resumed")
254
+ this.emit(run, { type: "run.resumed" });
255
+ }
256
+ for (const w of run.waiters.splice(0))
257
+ w(outcome);
258
+ }
259
+ /** The owner acted: refresh consent and release whatever the run was waiting on. */
260
+ resume(raw) {
261
+ const o = (raw && typeof raw === "object" ? raw : {});
262
+ const run = this.get(String(o.runId ?? ""));
263
+ if (run.state === "ended")
264
+ throw new RunnerInputError("The run has ended", 409);
265
+ for (const d of strList(o.consentedDomains))
266
+ run.consented.add(d);
267
+ for (const d of strList(o.denyDomains))
268
+ run.deny.add(d);
269
+ if (o.profileConsented === true)
270
+ run.profileConsented = true;
271
+ this.release(run, "resumed");
272
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
273
+ }
274
+ cancel(raw) {
275
+ const runId = String(raw?.runId ?? "");
276
+ const run = this.get(runId);
277
+ if (run.state !== "ended") {
278
+ run.cancelled = true;
279
+ this.release(run, "stopped");
280
+ if (run.child)
281
+ this.kill(run);
282
+ else
283
+ this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
284
+ }
285
+ return { runId, state: run.state };
286
+ }
287
+ status(raw) {
288
+ const o = (raw && typeof raw === "object" ? raw : {});
289
+ const run = this.get(String(o.runId ?? ""));
290
+ const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
291
+ return {
292
+ runId: run.envelope.runId,
293
+ state: run.state,
294
+ ...(run.pauseReason ? { pauseReason: run.pauseReason } : {}),
295
+ events: run.events.filter((e) => e.seq > after).slice(0, 500),
296
+ lastSeq: run.seq,
297
+ ...(run.result ? { result: run.result } : {}),
298
+ };
299
+ }
300
+ /** Does this token belong to this run? The bridge forwarder's only credential. */
301
+ authorizeBridge(runId, token) {
302
+ const run = this.runs.get(runId);
303
+ return !!run && run.state !== "ended" && token.length > 0 && token === run.token;
304
+ }
305
+ async bridge(raw) {
306
+ const o = (raw && typeof raw === "object" ? raw : {});
307
+ const run = this.get(String(o.runId ?? ""));
308
+ if (!run.bridge || run.state === "ended")
309
+ throw new RunnerInputError("The run is not active", 409);
310
+ if (o.op === "list")
311
+ return { tools: await run.bridge.listTools() };
312
+ if (o.op === "call" && typeof o.name === "string")
313
+ return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
314
+ throw new RunnerInputError("op must be list or call");
315
+ }
316
+ get(runId) {
317
+ const run = this.runs.get(runId);
318
+ if (!run)
319
+ throw new RunnerInputError(`No local browser run ${runId} on this runner (it may have restarted)`, 404);
320
+ return run;
321
+ }
322
+ kill(run) {
323
+ const child = run.child;
324
+ if (!child || child.exitCode !== null)
325
+ return;
326
+ child.kill("SIGTERM");
327
+ setTimeout(() => {
328
+ if (child.exitCode === null)
329
+ child.kill("SIGKILL");
330
+ }, 5_000).unref?.();
331
+ }
332
+ /** The CLI exited: assemble the result from what the bridge recorded and what the CLI said. */
333
+ finish(run, code) {
334
+ if (run.state === "ended")
335
+ return;
336
+ this.end(run, this.outcomeOf(run, code));
337
+ }
338
+ outcomeOf(run, code) {
339
+ const e = run.envelope;
340
+ const bridge = run.bridge;
341
+ const summary = bridge?.summary ?? finalText(e.engine, run.output);
342
+ const found = (bridge?.findings.length ?? 0) > 0;
343
+ if (run.cancelled)
344
+ return { outcome: "failed", error: "Cancelled by the owner" };
345
+ if (missingLogin(e.engine, run.output.join("\n")) && !found)
346
+ return { outcome: "failed", error: signInHelp(e.engine), engineAuth: "missing_login" };
347
+ const limit = `Stopped at the ${e.limits.maxMinutes}-minute limit.`;
348
+ if (run.timedOut)
349
+ return { outcome: found ? "completed" : "failed", summary: summary || limit, error: limit };
350
+ if (code !== 0 && !bridge?.summary) {
351
+ const tail = run.output.slice(-5).join("\n").slice(-800);
352
+ return { outcome: "failed", summary, error: `The ${e.engine} CLI exited with code ${code}${tail ? `: ${tail}` : ""}` };
353
+ }
354
+ return { outcome: "completed", summary };
355
+ }
356
+ end(run, r) {
357
+ if (run.state === "ended")
358
+ return;
359
+ if (run.state === "paused")
360
+ this.release(run, "stopped");
361
+ if (run.timer)
362
+ clearInterval(run.timer);
363
+ run.state = "ended";
364
+ run.endedAt = this.now();
365
+ run.result = {
366
+ runId: run.envelope.runId,
367
+ outcome: r.outcome,
368
+ findings: run.bridge?.findings ?? [],
369
+ sourceFailures: run.bridge?.sourceFailures ?? [],
370
+ summary: (r.summary ?? "").slice(0, 4000),
371
+ traceId: run.envelope.runId,
372
+ engineAuth: r.engineAuth ?? run.engineAuth,
373
+ ...(r.outcome === "failed" ? { error: (r.error ?? "The run failed without a reason.").slice(0, 1000) } : {}),
374
+ };
375
+ if (run.child && run.child.exitCode === null)
376
+ this.kill(run);
377
+ void run.browser?.stop().catch(() => undefined);
378
+ }
379
+ /** Drop ended runs past retention, with their folders. Custom workspaces are the owner's and are never deleted. */
380
+ sweep() {
381
+ const cutoff = this.now() - this.retentionMs;
382
+ for (const [id, run] of this.runs) {
383
+ if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
384
+ rmSync(run.dir, { recursive: true, force: true });
385
+ this.runs.delete(id);
386
+ }
387
+ }
388
+ // Folders left by a previous runner process (its runs are gone from memory).
389
+ if (!existsSync(this.root))
390
+ return;
391
+ for (const instance of readdirSync(this.root)) {
392
+ const instDir = join(this.root, instance);
393
+ for (const runId of readdirSync(instDir)) {
394
+ if (this.runs.has(runId))
395
+ continue;
396
+ const dir = join(instDir, runId);
397
+ if (statSync(dir).mtimeMs < cutoff)
398
+ rmSync(dir, { recursive: true, force: true });
399
+ }
400
+ }
401
+ }
402
+ /** Runner shutdown: stop every engine. Their runs end failed — the process that held them is going. */
403
+ closeAll() {
404
+ for (const run of this.runs.values()) {
405
+ if (run.state !== "ended") {
406
+ run.cancelled = true;
407
+ this.release(run, "stopped");
408
+ this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
409
+ }
410
+ }
411
+ }
412
+ }
@@ -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
  }
@@ -7,6 +7,15 @@ import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail
7
7
  export function createRunnerServer(runner) {
8
8
  return createServer(async (req, res) => {
9
9
  try {
10
+ // The research CLI's bridge forwarder (#944) holds a run-scoped token, not the runner's —
11
+ // accepted for this one path and that one run only, and still never from a browser origin.
12
+ if (!req.headers.origin && req.method === "POST" && (req.url || "").split("?")[0] === "/local-browser/bridge") {
13
+ const body = await readJson(req);
14
+ const token = String(req.headers["x-pags-bridge-token"] || "");
15
+ if (!runner.localBrowser.authorizeBridge(String(body.runId ?? ""), token))
16
+ return json(res, 401, { error: "Unauthorized" });
17
+ return json(res, 200, await runner.localBrowser.bridge(body));
18
+ }
10
19
  if (!authorize(req, runner.config)) {
11
20
  return json(res, 401, { error: "Unauthorized" });
12
21
  }
@@ -28,6 +37,7 @@ export async function startRunnerServer(config) {
28
37
  });
29
38
  const address = server.address();
30
39
  const actualPort = address.port;
40
+ runner.selfUrl = `http://${config.host}:${actualPort}`;
31
41
  return {
32
42
  runner,
33
43
  url: `http://${config.host}:${actualPort}`,
@@ -154,6 +164,19 @@ async function route(runner, req, res) {
154
164
  }
155
165
  // ── Brain-driven coding control (remote LLM drives a tmux coding CLI) ────
156
166
  // The tmux analogue of the /browser/* surface: start → capture → act → end.
167
+ // Local CLI browser research (#944). The API pulls state with /status — the runner never pushes.
168
+ if (req.method === "POST" && path === "/local-browser/run") {
169
+ return json(res, 202, runner.localBrowser.start(await readJson(req)));
170
+ }
171
+ if (req.method === "POST" && path === "/local-browser/status") {
172
+ return json(res, 200, runner.localBrowser.status(await readJson(req)));
173
+ }
174
+ if (req.method === "POST" && path === "/local-browser/resume") {
175
+ return json(res, 200, runner.localBrowser.resume(await readJson(req)));
176
+ }
177
+ if (req.method === "POST" && path === "/local-browser/cancel") {
178
+ return json(res, 200, runner.localBrowser.cancel(await readJson(req)));
179
+ }
157
180
  if (req.method === "POST" && path === "/coding/start") {
158
181
  const b = await readJson(req);
159
182
  return json(res, 200, runner.coding.start(b));
@@ -19,4 +19,7 @@ export const WORKFLOW_DRIVEN_TASKS = new Set([
19
19
  "browser.handoff",
20
20
  // #841: local Claude/Codex authors an FWS draft; PAGS retains the audit and deployment gate.
21
21
  "site_builder_runtime",
22
+ // #944: local CLI browser research. Its state is the LocalBrowserRuntime's run, not a task the
23
+ // runner executes from its task list, so a restart must not expire a card for it.
24
+ "local_browser.research",
22
25
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",