@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
|
@@ -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 (
|
|
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
|
|
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,312 @@
|
|
|
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, on the trace — naming the site and the owner's decision behind it when there is one. */
|
|
161
|
+
refuse(tool, reason, host) {
|
|
162
|
+
const consentId = host ? this.host.consentIdFor(host) : undefined;
|
|
163
|
+
this.host.emit({ type: "policy.decision", ...(host ? { domain: host } : {}), ...(consentId ? { consentId } : {}), detail: { tool, decision: "refused", reason } });
|
|
164
|
+
return text(reason, true);
|
|
165
|
+
}
|
|
166
|
+
/** Hosts already admitted on this run — a site is recorded as allowed once, not on every page. */
|
|
167
|
+
admitted = new Set();
|
|
168
|
+
allowed(host, basis) {
|
|
169
|
+
if (this.admitted.has(host))
|
|
170
|
+
return;
|
|
171
|
+
this.admitted.add(host);
|
|
172
|
+
const consentId = basis === "consent" ? this.host.consentIdFor(host) : undefined;
|
|
173
|
+
this.host.emit({ type: "policy.decision", domain: host, ...(consentId ? { consentId } : {}), detail: { decision: "allowed", basis } });
|
|
174
|
+
}
|
|
175
|
+
/** May the run be on this host — asking the owner, and waiting, when it is a new one. */
|
|
176
|
+
async admit(host, url) {
|
|
177
|
+
if (this.host.isDenied(host))
|
|
178
|
+
return `${host} is on this run's deny list.`;
|
|
179
|
+
if (this.host.isPermitted(host)) {
|
|
180
|
+
this.allowed(host, this.host.consentIdFor(host) ? "consent" : "allow_list");
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (this.host.allowListOnly())
|
|
184
|
+
return `${host} is not on this run's list of allowed sites.`;
|
|
185
|
+
this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
|
|
186
|
+
const outcome = await this.host.pause("consent_required", { domain: host });
|
|
187
|
+
if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
|
|
188
|
+
this.allowed(host, "consent");
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
|
|
192
|
+
}
|
|
193
|
+
async navigate(url) {
|
|
194
|
+
const host = hostOf(url);
|
|
195
|
+
if (!host)
|
|
196
|
+
return this.refuse("browser_navigate", "Only http(s) URLs can be opened.");
|
|
197
|
+
if (this.pages >= this.host.limits.maxPages)
|
|
198
|
+
return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
|
|
199
|
+
const refusal = await this.admit(host, url);
|
|
200
|
+
if (refusal)
|
|
201
|
+
return this.refuse("browser_navigate", refusal, host);
|
|
202
|
+
const res = await this.browser.callTool("browser_navigate", { url });
|
|
203
|
+
if (res.isError)
|
|
204
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
205
|
+
// A redirect is a second navigation the CLI did not choose. Following one within the same
|
|
206
|
+
// site (www., a jobs. subdomain) is fine; landing somewhere unrelated and unpermitted is not.
|
|
207
|
+
const state = await this.inspect();
|
|
208
|
+
const landedOn = state ? hostOf(state.url) : null;
|
|
209
|
+
const related = (a, b) => domainWithin(a, b) || domainWithin(b, a);
|
|
210
|
+
if (landedOn && !related(landedOn, host) && (this.host.isDenied(landedOn) || !this.host.isPermitted(landedOn))) {
|
|
211
|
+
await this.browser.callTool("browser_navigate_back", {}).catch(() => undefined);
|
|
212
|
+
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.`);
|
|
213
|
+
}
|
|
214
|
+
return this.landed(textOf(res), state);
|
|
215
|
+
}
|
|
216
|
+
async click(args) {
|
|
217
|
+
const ref = typeof args.target === "string" ? args.target : typeof args.ref === "string" ? args.ref : "";
|
|
218
|
+
const target = ref ? refRole(this.lastSnapshot, ref) : null;
|
|
219
|
+
const allowed = target && (CLICKABLE_ROLES.has(target.role) || (target.role === "button" && PAGINATION_NAME.test(target.name.trim())));
|
|
220
|
+
if (!allowed)
|
|
221
|
+
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.");
|
|
222
|
+
if (this.pages >= this.host.limits.maxPages)
|
|
223
|
+
return this.refuse("browser_click", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
|
|
224
|
+
const res = await this.browser.callTool("browser_click", { element: typeof args.element === "string" ? args.element : target.name, target: ref });
|
|
225
|
+
if (res.isError)
|
|
226
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
227
|
+
const state = await this.inspect();
|
|
228
|
+
const host = state ? hostOf(state.url) : null;
|
|
229
|
+
// A click that lands on a site this run may not be on is undone, and the CLI is told to
|
|
230
|
+
// navigate there instead — which is the path that asks the owner.
|
|
231
|
+
if (host && !this.visited.has(host) && (this.host.isDenied(host) || !this.host.isPermitted(host))) {
|
|
232
|
+
await this.browser.callTool("browser_navigate_back", {}).catch(() => undefined);
|
|
233
|
+
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.`);
|
|
234
|
+
}
|
|
235
|
+
return this.landed(textOf(res), state);
|
|
236
|
+
}
|
|
237
|
+
/** After a page change: count it, trace it, and stop for a person when the page needs one. */
|
|
238
|
+
async landed(toolText, known) {
|
|
239
|
+
const state = known ?? (await this.inspect());
|
|
240
|
+
if (!state)
|
|
241
|
+
return text(toolText);
|
|
242
|
+
this.pages++;
|
|
243
|
+
this.noteNavigation(state);
|
|
244
|
+
const host = hostOf(state.url) ?? "";
|
|
245
|
+
const blocker = state.captcha ? "captcha" : state.login ? "login_required" : null;
|
|
246
|
+
if (blocker) {
|
|
247
|
+
this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: blocker } });
|
|
248
|
+
const outcome = await this.host.pause(blocker, { domain: host });
|
|
249
|
+
if (outcome !== "resumed")
|
|
250
|
+
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);
|
|
251
|
+
const after = await this.inspect();
|
|
252
|
+
if (after && (after.captcha || after.login))
|
|
253
|
+
return text(`${host} still shows ${blocker === "captcha" ? "a captcha" : "a sign-in"}. Record it with report_source_failure (${blocker}) and move on.`, true);
|
|
254
|
+
return text(`Resumed after the owner handled ${host}. Take a browser_snapshot to read the page.`);
|
|
255
|
+
}
|
|
256
|
+
if (state.paywall) {
|
|
257
|
+
this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: "paywall" } });
|
|
258
|
+
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);
|
|
259
|
+
}
|
|
260
|
+
return text(toolText);
|
|
261
|
+
}
|
|
262
|
+
noteNavigation(state) {
|
|
263
|
+
const host = hostOf(state.url);
|
|
264
|
+
if (!host)
|
|
265
|
+
return;
|
|
266
|
+
this.visited.add(host);
|
|
267
|
+
this.host.emit({ type: "browser.navigated", url: state.url, domain: host, detail: redactDetail({ title: state.title }) });
|
|
268
|
+
}
|
|
269
|
+
async inspect() {
|
|
270
|
+
const res = await this.browser.callTool("browser_evaluate", { function: INSPECT_PAGE }).catch(() => null);
|
|
271
|
+
const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
|
|
272
|
+
if (!v || typeof v.url !== "string")
|
|
273
|
+
return null;
|
|
274
|
+
return { url: v.url, title: typeof v.title === "string" ? v.title : "", captcha: !!v.captcha, login: !!v.login, paywall: !!v.paywall };
|
|
275
|
+
}
|
|
276
|
+
recordFinding(args) {
|
|
277
|
+
const title = typeof args.title === "string" ? args.title.trim() : "";
|
|
278
|
+
const url = typeof args.url === "string" ? args.url.trim() : "";
|
|
279
|
+
const evidence = typeof args.evidence === "string" ? args.evidence.trim() : "";
|
|
280
|
+
const host = hostOf(url);
|
|
281
|
+
if (!title || !evidence || !host)
|
|
282
|
+
return text("A finding needs a title, an http(s) url and evidence text.", true);
|
|
283
|
+
if (![...this.visited].some((v) => domainWithin(host, v) || domainWithin(v, host))) {
|
|
284
|
+
return text(`${host} was not opened in this run. A finding must cite a page you navigated to.`, true);
|
|
285
|
+
}
|
|
286
|
+
if (this.findings.some((f) => f.url === url && f.title === title))
|
|
287
|
+
return text("Already recorded.");
|
|
288
|
+
if (this.findings.length >= 200)
|
|
289
|
+
return text("This run holds at most 200 findings. Call finish_research.", true);
|
|
290
|
+
const fields = {};
|
|
291
|
+
const raw = args.fields && typeof args.fields === "object" && !Array.isArray(args.fields) ? args.fields : {};
|
|
292
|
+
for (const [k, v] of Object.entries(redactDetail(raw) ?? {})) {
|
|
293
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean" || v === null)
|
|
294
|
+
fields[k] = v;
|
|
295
|
+
}
|
|
296
|
+
const finding = { title: title.slice(0, 500), url: url.slice(0, 2000), evidence: evidence.slice(0, 4000), fields };
|
|
297
|
+
this.findings.push(finding);
|
|
298
|
+
this.host.emit({ type: "finding.parsed", url: finding.url, domain: host, detail: { title: finding.title } });
|
|
299
|
+
return text(`Recorded finding ${this.findings.length}.`);
|
|
300
|
+
}
|
|
301
|
+
reportFailure(args) {
|
|
302
|
+
const url = typeof args.url === "string" ? args.url.trim() : "";
|
|
303
|
+
const reason = LOCAL_BROWSER_SOURCE_FAILURE_REASONS.includes(args.reason) ? args.reason : null;
|
|
304
|
+
if (!url || !reason)
|
|
305
|
+
return text(`A source failure needs a url and a reason: ${LOCAL_BROWSER_SOURCE_FAILURE_REASONS.join(", ")}.`, true);
|
|
306
|
+
if (this.sourceFailures.length >= 200)
|
|
307
|
+
return text("This run holds at most 200 source failures.", true);
|
|
308
|
+
const detail = typeof args.detail === "string" ? args.detail.slice(0, 1000) : undefined;
|
|
309
|
+
this.sourceFailures.push(detail ? { url: url.slice(0, 2000), reason, detail } : { url: url.slice(0, 2000), reason });
|
|
310
|
+
return text("Recorded.");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
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", "review.decision"];
|
|
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
|
+
/** An env var NAME whose value is a credential. */
|
|
56
|
+
const SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|SESSION|AUTH/i;
|
|
57
|
+
/** Credential-shaped text, wherever it appears — the patterns a CLI or a page tends to print. */
|
|
58
|
+
const SECRET_PATTERNS = [
|
|
59
|
+
// NAME=value for a credential-named variable: keep the name, drop the value.
|
|
60
|
+
[/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)\s*[=:]\s*["']?[^\s"']+["']?/g, "$1=[REDACTED]"],
|
|
61
|
+
[/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{10,}/gi, "$1 [REDACTED]"],
|
|
62
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]"],
|
|
63
|
+
[/\b(?:ghp|gho|ghs|ghu|github_pat|xox[abpr])_[A-Za-z0-9_]{20,}/g, "[REDACTED]"],
|
|
64
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[REDACTED]"],
|
|
65
|
+
// Base64-ish runs over 40 characters: keys, tokens, session blobs.
|
|
66
|
+
[/[A-Za-z0-9+/_-]{41,}={0,2}/g, "[REDACTED]"],
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* The values of credential-named variables in an environment, longest first — what `redactText`
|
|
70
|
+
* must never let through even when the value has no recognisable shape.
|
|
71
|
+
*/
|
|
72
|
+
export function secretEnvValues(env) {
|
|
73
|
+
return Object.entries(env)
|
|
74
|
+
.filter(([k, v]) => SECRET_ENV_NAME.test(k) && typeof v === "string" && v.trim().length >= 8)
|
|
75
|
+
.map(([, v]) => v.trim())
|
|
76
|
+
.sort((a, b) => b.length - a.length);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove credentials from free text before it is stored (#947): first every known secret value,
|
|
80
|
+
* verbatim, then anything credential-shaped. Used on CLI output that becomes an error, on result
|
|
81
|
+
* text, and on every string in a trace detail.
|
|
82
|
+
*/
|
|
83
|
+
export function redactText(text, secrets = []) {
|
|
84
|
+
let out = text;
|
|
85
|
+
for (const s of secrets)
|
|
86
|
+
if (s.length >= 8)
|
|
87
|
+
out = out.split(s).join("[REDACTED]");
|
|
88
|
+
for (const [re, sub] of SECRET_PATTERNS)
|
|
89
|
+
out = out.replace(re, sub);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Drop sensitive keys, redact credential-shaped values and bound sizes. A trace is for a supervisor
|
|
94
|
+
* to read, so nothing in it may carry a cookie, a password, a form value or a key — whatever the
|
|
95
|
+
* runner sent.
|
|
96
|
+
*/
|
|
97
|
+
export function redactDetail(detail, depth = 0, secrets = []) {
|
|
98
|
+
if (!detail || typeof detail !== "object" || Array.isArray(detail) || depth > 3)
|
|
99
|
+
return undefined;
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [k, v] of Object.entries(detail).slice(0, 40)) {
|
|
102
|
+
if (SENSITIVE_KEY.test(k))
|
|
103
|
+
continue;
|
|
104
|
+
if (typeof v === "string")
|
|
105
|
+
out[k] = redactText(v.slice(0, 1000), secrets);
|
|
106
|
+
else if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
107
|
+
out[k] = v;
|
|
108
|
+
else if (Array.isArray(v))
|
|
109
|
+
out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? redactText(x.slice(0, 300), secrets) : typeof x === "number" || typeof x === "boolean" ? x : null));
|
|
110
|
+
else if (typeof v === "object") {
|
|
111
|
+
const nested = redactDetail(v, depth + 1, secrets);
|
|
112
|
+
if (nested)
|
|
113
|
+
out[k] = nested;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
119
|
+
const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
120
|
+
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
121
|
+
/** A validated event, or null. */
|
|
122
|
+
export function parseLocalBrowserEvent(raw) {
|
|
123
|
+
if (!raw || typeof raw !== "object")
|
|
124
|
+
return null;
|
|
125
|
+
const o = raw;
|
|
126
|
+
const type = oneOf(LOCAL_BROWSER_EVENT_TYPES, o.type);
|
|
127
|
+
const at = str(o.at, 40);
|
|
128
|
+
if (!type || !at || Number.isNaN(Date.parse(at)))
|
|
129
|
+
return null;
|
|
130
|
+
const event = { type, at };
|
|
131
|
+
const url = str(o.url, 2000);
|
|
132
|
+
if (url && isHttpUrl(url))
|
|
133
|
+
event.url = url;
|
|
134
|
+
const domain = str(o.domain, 253);
|
|
135
|
+
if (domain)
|
|
136
|
+
event.domain = domain.toLowerCase();
|
|
137
|
+
const pauseReason = oneOf(LOCAL_BROWSER_PAUSE_REASONS, o.pauseReason);
|
|
138
|
+
if (type === "run.paused" && !pauseReason)
|
|
139
|
+
return null;
|
|
140
|
+
if (pauseReason)
|
|
141
|
+
event.pauseReason = pauseReason;
|
|
142
|
+
const consentId = str(o.consentId, 100);
|
|
143
|
+
if (consentId)
|
|
144
|
+
event.consentId = consentId;
|
|
145
|
+
const detail = redactDetail(o.detail);
|
|
146
|
+
if (detail && Object.keys(detail).length)
|
|
147
|
+
event.detail = detail;
|
|
148
|
+
return event;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A validated result envelope, or the reason it is not one.
|
|
152
|
+
*
|
|
153
|
+
* Wrapped as `{ result }` on purpose: a FAILED envelope carries its own `error` field, so returning
|
|
154
|
+
* the envelope bare made `"error" in parsed` true for every failed run and rejected it as invalid.
|
|
155
|
+
*/
|
|
156
|
+
export function parseLocalBrowserResult(raw) {
|
|
157
|
+
if (!raw || typeof raw !== "object")
|
|
158
|
+
return { error: "result must be an object" };
|
|
159
|
+
const o = raw;
|
|
160
|
+
const runId = str(o.runId, 100);
|
|
161
|
+
if (!runId)
|
|
162
|
+
return { error: "runId is required" };
|
|
163
|
+
const outcome = o.outcome === "completed" || o.outcome === "failed" ? o.outcome : null;
|
|
164
|
+
if (!outcome)
|
|
165
|
+
return { error: "outcome must be completed or failed" };
|
|
166
|
+
const traceId = str(o.traceId, 100);
|
|
167
|
+
if (!traceId)
|
|
168
|
+
return { error: "traceId is required" };
|
|
169
|
+
const engineAuth = oneOf(LOCAL_BROWSER_ENGINE_AUTHS, o.engineAuth);
|
|
170
|
+
if (!engineAuth)
|
|
171
|
+
return { error: `engineAuth must be one of ${LOCAL_BROWSER_ENGINE_AUTHS.join(", ")}` };
|
|
172
|
+
if (!Array.isArray(o.findings) || !Array.isArray(o.sourceFailures))
|
|
173
|
+
return { error: "findings and sourceFailures must be arrays" };
|
|
174
|
+
if (o.findings.length > LOCAL_BROWSER_CAPS.findings)
|
|
175
|
+
return { error: `at most ${LOCAL_BROWSER_CAPS.findings} findings` };
|
|
176
|
+
if (o.sourceFailures.length > LOCAL_BROWSER_CAPS.sourceFailures)
|
|
177
|
+
return { error: `at most ${LOCAL_BROWSER_CAPS.sourceFailures} source failures` };
|
|
178
|
+
const findings = [];
|
|
179
|
+
for (const [i, f] of o.findings.entries()) {
|
|
180
|
+
const r = (f && typeof f === "object" ? f : {});
|
|
181
|
+
const title = str(r.title, 500);
|
|
182
|
+
const url = str(r.url, 2000);
|
|
183
|
+
const evidence = str(r.evidence, LOCAL_BROWSER_CAPS.text);
|
|
184
|
+
if (!title || !url || !isHttpUrl(url) || !evidence)
|
|
185
|
+
return { error: `findings[${i}] needs title, an http(s) url and evidence` };
|
|
186
|
+
const fields = {};
|
|
187
|
+
const rawFields = r.fields && typeof r.fields === "object" && !Array.isArray(r.fields) ? r.fields : {};
|
|
188
|
+
for (const [k, v] of Object.entries(rawFields).slice(0, LOCAL_BROWSER_CAPS.fieldCount)) {
|
|
189
|
+
if (SENSITIVE_KEY.test(k))
|
|
190
|
+
continue;
|
|
191
|
+
if (typeof v === "string")
|
|
192
|
+
fields[k.slice(0, 100)] = v.slice(0, LOCAL_BROWSER_CAPS.text);
|
|
193
|
+
else if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
194
|
+
fields[k.slice(0, 100)] = v;
|
|
195
|
+
}
|
|
196
|
+
findings.push({ title, url, evidence, fields });
|
|
197
|
+
}
|
|
198
|
+
const sourceFailures = [];
|
|
199
|
+
for (const [i, f] of o.sourceFailures.entries()) {
|
|
200
|
+
const r = (f && typeof f === "object" ? f : {});
|
|
201
|
+
const url = str(r.url, 2000);
|
|
202
|
+
const reason = oneOf(LOCAL_BROWSER_SOURCE_FAILURE_REASONS, r.reason);
|
|
203
|
+
if (!url || !reason)
|
|
204
|
+
return { error: `sourceFailures[${i}] needs url and a known reason` };
|
|
205
|
+
const detail = str(r.detail, 1000);
|
|
206
|
+
sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
|
|
207
|
+
}
|
|
208
|
+
const out = { runId, outcome, findings, sourceFailures, summary: redactText(str(o.summary, LOCAL_BROWSER_CAPS.text) ?? ""), traceId, engineAuth };
|
|
209
|
+
// Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
|
|
210
|
+
const rawError = str(o.error, 1000);
|
|
211
|
+
const error = rawError === null ? null : redactText(rawError);
|
|
212
|
+
if (outcome === "failed")
|
|
213
|
+
out.error = error ?? "The run failed without a reason.";
|
|
214
|
+
return { result: out };
|
|
215
|
+
}
|