@proagentstore/cli 0.4.78 → 0.4.80

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How an application run launches its CLI (#957). The research launch (`local-browser/engine.ts`) —
3
+ * no built-in web or shell tools, the bridge as the only MCP server — with two differences:
4
+ *
5
+ * - Codex pre-approves the APPLY bridge's own tool names, not the research set;
6
+ * - subscription-only, like the Tailor: provider API keys are always stripped, `machine` also
7
+ * drops the Claude subscription token, and the runtime refuses a run whose spawn env would
8
+ * still carry a key.
9
+ */
10
+ import { artifactEngineEnv, observedArtifactAuth } from "../local-artifact/engine.js";
11
+ import { buildEngineSpec, BRIDGE_SERVER_NAME } from "../local-browser/engine.js";
12
+ import { APPLY_BRIDGE_TOOL_NAMES } from "./bridge.js";
13
+ export { observedArtifactAuth as observedApplyAuth };
14
+ export function buildApplyEngineSpec(e, prompt, bridge, mcpConfigPath) {
15
+ return buildEngineSpec({
16
+ engine: e.engine,
17
+ authMode: e.authMode,
18
+ prompt,
19
+ bridge,
20
+ mcpConfigPath,
21
+ toolTimeoutMs: e.limits.maxMinutes * 60_000,
22
+ baseEnv: artifactEngineEnv(e.authMode),
23
+ toolNames: APPLY_BRIDGE_TOOL_NAMES,
24
+ });
25
+ }
26
+ /** The brief. The bridge, not this text, is what enforces the rules. */
27
+ export function applyPrompt(e, sources) {
28
+ const t = (name) => `mcp__${BRIDGE_SERVER_NAME}__${name}`;
29
+ const job = [`Title: ${e.job.title}`, e.job.company ? `Company: ${e.job.company}` : "", e.job.location ? `Location: ${e.job.location}` : "", `Application page: ${e.applicationUrl}`].filter(Boolean).join("\n");
30
+ const finish = e.policy.mode === "auto_submit"
31
+ ? `- When every field you can ground is filled and the documents are attached, you may press the final submit button ONCE. If anything is uncertain, call ${t("ready_for_review")} instead.`
32
+ : `- Do NOT submit. When every field you can ground is filled and the documents are attached, call ${t("ready_for_review")} with a short summary and stop.`;
33
+ return [
34
+ "You fill in ONE job application for the owner of this machine, in their browser, from their own information.",
35
+ "",
36
+ "<job>",
37
+ job,
38
+ "</job>",
39
+ "",
40
+ ...sources.map((s) => `<source name="${s.label}">\n${s.text}\n</source>\n`),
41
+ "How to work:",
42
+ `- Your only tools are from the ${BRIDGE_SERVER_NAME} server. Open the application page with ${t("browser_navigate")}, read it with ${t("browser_snapshot")}, and act on elements by their ref.`,
43
+ `- Every answer — ${t("browser_type")}, ${t("browser_select_option")}, or a click on a checkbox, radio or option — must carry source_quote: the exact text from a source above that the value comes from. Type values exactly as the source writes them.`,
44
+ `- If a question has no answer in the sources, or is ambiguous, call ${t("request_answer")}. Never guess, never invent, never pick a "safe" default.`,
45
+ `- To attach a document, click the field's upload control, then call ${t("upload_artifact")} with kind resume or cover_letter.`,
46
+ "- Never sign in, solve a captcha, accept terms or work around any block; the run pauses for the owner by itself.",
47
+ `- Stay on the application's site. Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
48
+ finish,
49
+ ].join("\n");
50
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Local application execution on the runner (#957) — the Job Application Runner's hands.
3
+ *
4
+ * One run = one Codex or Claude Code process, signed in on this machine, filling ONE application
5
+ * through the apply bridge (bridge.ts) in a real browser. The runtime owns what PAGS later pulls
6
+ * with `status`, as local browser research does (`local-browser/runtime.ts`):
7
+ *
8
+ * - idempotency: a start with a requestId this runner already holds returns that run, so a
9
+ * replayed materials_ready event can never fill — or upload, or submit — a second time;
10
+ * - one active application run per instance;
11
+ * - the owner's answer sources and approved artifacts, read and hashed HERE before the CLI starts.
12
+ * An artifact whose hash no longer matches what #956 reported is refused, not uploaded;
13
+ * - pauses: the bridge holds a call until `resume` (with the owner's answer or a newly allowed
14
+ * site), cancel, or the time limit — active time only, so waiting on the owner is not counted;
15
+ * - the result: `awaiting_review`, `submitted` (only with the site's confirmation), `blocked`
16
+ * with the reason, or `failed`.
17
+ *
18
+ * Runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS reads as
19
+ * "the runner lost this run".
20
+ */
21
+ import { spawn as nodeSpawn } from "node:child_process";
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join, resolve, sep } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { RunnerInputError } from "../errors.js";
28
+ import { isHomeRelative, isWorkspaceRelative } from "../local-artifact/contract.js";
29
+ import { resolveSource } from "../local-artifact/runtime.js";
30
+ import { domainWithin } from "../local-browser/bridge.js";
31
+ import { BRIDGE_ENV } from "../local-browser/bridge-stdio.js";
32
+ import { redactText, secretEnvValues } from "../local-browser/contract.js";
33
+ import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
34
+ import { resolveWorkspacePath } from "../local-browser/runtime.js";
35
+ import { ApplyBridge } from "./bridge.js";
36
+ import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
37
+ import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
38
+ const MAX_EVENTS = 1000;
39
+ const OUTPUT_LINES = 400;
40
+ const SOURCE_BYTES = 256 * 1024;
41
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
42
+ const HASH = /^[a-f0-9]{64}$/;
43
+ /** One application at a time per agent: two runs on one employer's form is how duplicates happen. */
44
+ const MAX_ACTIVE = 1;
45
+ class Blocked extends Error {
46
+ reason;
47
+ questions;
48
+ constructor(reason, questions) {
49
+ super(reason);
50
+ this.reason = reason;
51
+ this.questions = questions;
52
+ }
53
+ }
54
+ const posInt = (v, max) => (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max ? v : null);
55
+ const strList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").map((x) => x.trim().toLowerCase()).filter(Boolean) : []);
56
+ const sha256 = (data) => createHash("sha256").update(data).digest("hex");
57
+ /** The envelope, validated at the runner's boundary. */
58
+ export function parseApplyEnvelope(raw) {
59
+ const o = (raw && typeof raw === "object" ? raw : {});
60
+ const bad = (what) => new RunnerInputError(`Invalid application task: ${what}`);
61
+ if (o.type !== LOCAL_APPLY_TASK_TYPE)
62
+ throw bad(`type must be ${LOCAL_APPLY_TASK_TYPE}`);
63
+ for (const k of ["runId", "requestId", "instanceId", "applicationId"])
64
+ if (typeof o[k] !== "string" || !SAFE_ID.test(o[k]))
65
+ throw bad(`${k} is required`);
66
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(o.runId))
67
+ throw bad("runId must be a plain id");
68
+ if (!LOCAL_APPLY_ENGINES.includes(o.engine))
69
+ throw bad("engine must be claude or codex");
70
+ if (!LOCAL_APPLY_AUTH_MODES.includes(o.authMode))
71
+ throw bad("authMode must be machine or subscription — a provider API key is never used");
72
+ if (o.browserProfile !== "isolated" && o.browserProfile !== "default")
73
+ throw bad("browserProfile must be isolated or default");
74
+ if (typeof o.applicationUrl !== "string" || !/^https?:\/\/[^\s/]+/i.test(o.applicationUrl) || o.applicationUrl.length > 2000)
75
+ throw bad("applicationUrl must be an http(s) URL");
76
+ const job = (o.job && typeof o.job === "object" ? o.job : {});
77
+ if (typeof job.title !== "string" || !job.title.trim())
78
+ throw bad("job.title is required");
79
+ if (typeof o.workspace !== "string" || !isHomeRelative(o.workspace))
80
+ throw bad('workspace must be a folder written "~/…"');
81
+ const sources = [];
82
+ for (const s of Array.isArray(o.sources) ? o.sources.slice(0, 4) : []) {
83
+ const r = (s && typeof s === "object" ? s : {});
84
+ if ((r.role !== "profile" && r.role !== "answers") || typeof r.path !== "string" || !isWorkspaceRelative(r.path) || r.path.split("/")[0] === "applications")
85
+ throw bad("each source needs a role (profile, answers) and a path inside the workspace");
86
+ sources.push({ role: r.role, path: r.path });
87
+ }
88
+ const artifacts = [];
89
+ for (const a of Array.isArray(o.artifacts) ? o.artifacts.slice(0, 2) : []) {
90
+ const r = (a && typeof a === "object" ? a : {});
91
+ if ((r.kind !== "resume" && r.kind !== "cover_letter") || typeof r.path !== "string" || !isHomeRelative(r.path) || typeof r.sha256 !== "string" || !HASH.test(r.sha256))
92
+ throw bad("each artifact needs a kind, a ~/ path and a sha256");
93
+ if (artifacts.some((x) => x.kind === r.kind))
94
+ throw bad(`artifact ${r.kind} is listed twice`);
95
+ artifacts.push({ kind: r.kind, path: r.path, sha256: r.sha256 });
96
+ }
97
+ const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
98
+ if (!LOCAL_APPLY_MODES.includes(p.mode))
99
+ throw bad("policy.mode must be fill_and_review or auto_submit");
100
+ const gate = (p.submitGate && typeof p.submitGate === "object" ? p.submitGate : null);
101
+ const gateId = gate && typeof gate.gateId === "string" && SAFE_ID.test(gate.gateId) ? gate.gateId : null;
102
+ // auto_submit without the gate PAGS evaluated is not a mode this runner will run.
103
+ if (p.mode === "auto_submit" && !gateId)
104
+ throw bad("auto_submit requires policy.submitGate");
105
+ const allowDomains = strList(p.allowDomains);
106
+ if (!allowDomains.length)
107
+ throw bad("policy.allowDomains must name the application's site");
108
+ const l = (o.limits && typeof o.limits === "object" ? o.limits : {});
109
+ const limits = { maxMinutes: posInt(l.maxMinutes, 60) ?? 0, maxPages: posInt(l.maxPages, 200) ?? 0, maxActions: posInt(l.maxActions, 1000) ?? 0 };
110
+ if (Object.values(limits).some((v) => !v))
111
+ throw bad("limits are out of range");
112
+ const opt = (v) => (typeof v === "string" && v.trim() ? v.trim().slice(0, 300) : undefined);
113
+ return {
114
+ type: LOCAL_APPLY_TASK_TYPE,
115
+ runId: o.runId,
116
+ requestId: o.requestId,
117
+ instanceId: o.instanceId,
118
+ applicationId: o.applicationId,
119
+ engine: o.engine,
120
+ authMode: o.authMode,
121
+ browserProfile: o.browserProfile,
122
+ applicationUrl: o.applicationUrl,
123
+ job: { title: job.title.trim().slice(0, 300), company: opt(job.company), location: opt(job.location) },
124
+ workspace: o.workspace,
125
+ sources,
126
+ artifacts,
127
+ policy: { mode: p.mode, allowDomains, ...(p.mode === "auto_submit" && gateId ? { submitGate: { gateId } } : {}) },
128
+ limits,
129
+ };
130
+ }
131
+ /**
132
+ * An approved artifact on the real disk: inside the workspace's `applications/`, and still the
133
+ * file #956 hashed. Anything else is not uploaded.
134
+ */
135
+ export function verifyArtifact(workspace, home, a) {
136
+ const label = a.kind.replace("_", " ");
137
+ try {
138
+ const real = realpathSync(resolve(realpathSync(home), a.path.slice(2)));
139
+ if (!real.startsWith(join(workspace, "applications") + sep))
140
+ return { error: `The approved ${label} is not inside the workspace's applications/ folder.` };
141
+ const buf = readFileSync(real);
142
+ if (sha256(buf) !== a.sha256)
143
+ return { error: `The approved ${label} at ${a.path} has changed since it was generated; it will not be uploaded.` };
144
+ return { path: real, text: buf.toString("utf8") };
145
+ }
146
+ catch {
147
+ return { error: `The approved ${label} at ${a.path} could not be read.` };
148
+ }
149
+ }
150
+ export class LocalApplyRuntime {
151
+ deps;
152
+ runs = new Map();
153
+ root;
154
+ now;
155
+ spawn;
156
+ retentionMs;
157
+ constructor(deps) {
158
+ this.deps = deps;
159
+ this.root = join(deps.dataDir, "local-apply");
160
+ this.now = deps.now ?? Date.now;
161
+ this.spawn = deps.spawn ?? nodeSpawn;
162
+ this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
163
+ }
164
+ start(raw) {
165
+ const envelope = parseApplyEnvelope(raw);
166
+ this.sweep();
167
+ const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
168
+ if (same)
169
+ return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
170
+ if (this.runs.has(envelope.runId))
171
+ throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
172
+ const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
173
+ if (active >= MAX_ACTIVE)
174
+ throw new RunnerInputError("This machine is already filling an application for this agent; one at a time.", 409);
175
+ const selfUrl = this.deps.selfUrl();
176
+ if (!selfUrl)
177
+ throw new RunnerInputError("The runner is still starting; try again in a moment.", 409);
178
+ const dir = join(this.root, envelope.instanceId.replace(/[^A-Za-z0-9_-]/g, "_"), envelope.runId);
179
+ mkdirSync(join(dir, "scratch"), { recursive: true });
180
+ const run = {
181
+ envelope,
182
+ dir,
183
+ token: randomBytes(24).toString("hex"),
184
+ state: "running",
185
+ events: [],
186
+ seq: 0,
187
+ allow: new Set(envelope.policy.allowDomains),
188
+ grounding: [],
189
+ secrets: secretEnvValues(process.env),
190
+ activeSince: this.now(),
191
+ activeMs: 0,
192
+ waiters: [],
193
+ output: [],
194
+ engineAuth: "unknown",
195
+ cancelled: false,
196
+ timedOut: false,
197
+ };
198
+ this.runs.set(envelope.runId, run);
199
+ void this.launch(run, selfUrl).catch((err) => {
200
+ if (err instanceof Blocked)
201
+ this.end(run, { outcome: "blocked", blockReason: err.reason, questions: err.questions });
202
+ else
203
+ this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
204
+ });
205
+ return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
206
+ }
207
+ async launch(run, selfUrl) {
208
+ const e = run.envelope;
209
+ const home = this.deps.homeDir ?? homedir();
210
+ let workspace;
211
+ try {
212
+ workspace = resolveWorkspacePath(e.workspace, home);
213
+ }
214
+ catch (err) {
215
+ throw new Blocked("source_unavailable", [`The workspace ${e.workspace} cannot be used: ${err instanceof Error ? err.message : String(err)}`]);
216
+ }
217
+ const blocks = [];
218
+ const missing = [];
219
+ if (!e.sources.some((s) => s.role === "profile"))
220
+ missing.push("No profile source is configured. Choose one in the Application Runner settings.");
221
+ for (const s of e.sources) {
222
+ const shown = `${e.workspace}/${s.path}`;
223
+ try {
224
+ const buf = readFileSync(resolveSource(workspace, s.path));
225
+ if (!buf.length || buf.length > SOURCE_BYTES)
226
+ throw new Error("empty or too large");
227
+ blocks.push({ label: s.role, text: buf.toString("utf8") });
228
+ this.emit(run, { type: "source.read", detail: { role: s.role, path: shown, sha256: sha256(buf), bytes: buf.length } });
229
+ }
230
+ catch {
231
+ missing.push(`Your ${s.role} at ${shown} could not be read. Add it, or choose another file in the Application Runner settings.`);
232
+ }
233
+ }
234
+ if (missing.length)
235
+ throw new Blocked("source_unavailable", missing);
236
+ for (const a of e.artifacts) {
237
+ const v = verifyArtifact(workspace, home, a);
238
+ if ("error" in v)
239
+ throw new Blocked("artifact_changed", [`${v.error} Re-run tailoring for this application.`]);
240
+ blocks.push({ label: a.kind, text: v.text });
241
+ }
242
+ run.grounding = blocks.map((b) => b.text);
243
+ const bridgeScript = this.deps.bridgeScript ?? fileURLToPath(new URL("../local-browser/bridge-stdio.js", import.meta.url));
244
+ const spec = buildApplyEngineSpec(e, applyPrompt(e, blocks), { command: process.execPath, args: [bridgeScript], env: { [BRIDGE_ENV.url]: selfUrl, [BRIDGE_ENV.runId]: e.runId, [BRIDGE_ENV.token]: run.token } }, join(run.dir, "mcp.json"));
245
+ run.secrets = secretEnvValues({ ...process.env, ...spec.env });
246
+ run.engineAuth = observedApplyAuth(e.engine, spec.env);
247
+ this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth, mode: e.policy.mode } });
248
+ if (run.engineAuth === "api-key")
249
+ throw new Blocked("api_key_refused", ["A provider API key would reach the CLI. Applications run on this machine's sign-in only — remove the key from the runner's environment."]);
250
+ run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
251
+ const artifactsByKind = new Map(e.artifacts.map((a) => [a.kind, a]));
252
+ run.bridge = new ApplyBridge(run.browser.tools, {
253
+ emit: (ev) => this.emit(run, ev),
254
+ pause: (p) => this.pause(run, p),
255
+ isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
256
+ grounding: () => run.grounding.join("\n"),
257
+ artifactPath: (kind) => {
258
+ const a = artifactsByKind.get(kind);
259
+ if (!a)
260
+ return { error: `No approved ${kind.replace("_", " ")} for this application.` };
261
+ const v = verifyArtifact(workspace, home, a);
262
+ return "error" in v ? v : { path: v.path, sha256: a.sha256 };
263
+ },
264
+ overTime: () => this.activeMs(run) >= e.limits.maxMinutes * 60_000,
265
+ now: this.now,
266
+ limits: e.limits,
267
+ mode: e.policy.mode,
268
+ gateId: e.policy.submitGate?.gateId,
269
+ });
270
+ if (spec.mcpConfig)
271
+ writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
272
+ const child = this.spawn(spec.command, spec.args, { cwd: join(run.dir, "scratch"), env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
273
+ run.child = child;
274
+ this.emit(run, { type: "engine.started", detail: { engine: e.engine, mode: e.policy.mode } });
275
+ let buf = "";
276
+ const keep = (chunk) => {
277
+ buf += chunk.toString();
278
+ const lines = buf.split("\n");
279
+ buf = lines.pop() ?? "";
280
+ for (const line of lines)
281
+ if (line.trim())
282
+ run.output.push(line.slice(0, 20_000));
283
+ if (run.output.length > OUTPUT_LINES)
284
+ run.output.splice(0, run.output.length - OUTPUT_LINES);
285
+ };
286
+ child.stdout?.on("data", keep);
287
+ child.stderr?.on("data", keep);
288
+ child.on("error", (err) => {
289
+ this.end(run, { outcome: "failed", error: err.code === "ENOENT" ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
290
+ });
291
+ child.on("close", (code) => {
292
+ if (buf.trim())
293
+ run.output.push(buf);
294
+ this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
295
+ this.end(run, this.outcomeOf(run, code ?? -1));
296
+ });
297
+ run.timer = setInterval(() => {
298
+ if (run.state !== "ended" && this.activeMs(run) >= e.limits.maxMinutes * 60_000 + 60_000) {
299
+ run.timedOut = true;
300
+ this.kill(run);
301
+ }
302
+ }, 5_000);
303
+ run.timer.unref?.();
304
+ }
305
+ /** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
306
+ outcomeOf(run, code) {
307
+ const e = run.envelope;
308
+ const b = run.bridge;
309
+ const summary = b?.summary ?? finalText(e.engine, run.output);
310
+ if (run.cancelled)
311
+ return { outcome: "failed", summary, error: "Cancelled by the owner" };
312
+ if (b?.submitted)
313
+ return { outcome: "submitted", summary, submitted: b.submitted };
314
+ if (b?.submitAttempted)
315
+ return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
316
+ if (b?.reviewReady)
317
+ return { outcome: "awaiting_review", summary };
318
+ if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
319
+ return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
320
+ if (b?.blocked)
321
+ return { outcome: "blocked", summary, blockReason: b.blocked.reason, questions: b.blocked.questions };
322
+ if (run.timedOut)
323
+ return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`] };
324
+ const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
325
+ return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`] };
326
+ }
327
+ activeMs(run) {
328
+ return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
329
+ }
330
+ emit(run, ev) {
331
+ if (run.events.length >= MAX_EVENTS)
332
+ return;
333
+ run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
334
+ }
335
+ pause(run, p) {
336
+ if (run.state === "ended" || run.cancelled)
337
+ return Promise.resolve("stopped");
338
+ if (run.state === "running") {
339
+ run.activeMs += this.now() - run.activeSince;
340
+ run.state = "paused";
341
+ run.pause = p;
342
+ this.emit(run, { type: "run.paused", pauseReason: p.reason, ...(p.url ? { url: p.url } : {}), ...(p.domain ? { domain: p.domain } : {}) });
343
+ }
344
+ return new Promise((done) => run.waiters.push(done));
345
+ }
346
+ release(run, outcome) {
347
+ if (run.state === "paused") {
348
+ run.state = "running";
349
+ run.pause = undefined;
350
+ run.activeSince = this.now();
351
+ if (outcome === "resumed")
352
+ this.emit(run, { type: "run.resumed" });
353
+ }
354
+ for (const w of run.waiters.splice(0))
355
+ w(outcome);
356
+ }
357
+ /** The owner acted: add their answers to what values may be grounded in, admit any site they allowed, release. */
358
+ resume(raw) {
359
+ const o = (raw && typeof raw === "object" ? raw : {});
360
+ const run = this.get(String(o.runId ?? ""));
361
+ if (run.state === "ended")
362
+ throw new RunnerInputError("The run has ended", 409);
363
+ for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
364
+ const r = (a && typeof a === "object" ? a : {});
365
+ if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
366
+ run.grounding.push(`Q: ${r.question.slice(0, LOCAL_APPLY_CAPS.questionChars)}\nA: ${r.answer.trim().slice(0, LOCAL_APPLY_CAPS.answerChars)}`);
367
+ }
368
+ for (const d of strList(o.allowDomains))
369
+ run.allow.add(d);
370
+ this.release(run, "resumed");
371
+ return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
372
+ }
373
+ cancel(raw) {
374
+ const runId = String(raw?.runId ?? "");
375
+ const run = this.get(runId);
376
+ if (run.state !== "ended") {
377
+ run.cancelled = true;
378
+ this.release(run, "stopped");
379
+ if (run.child && run.child.exitCode === null)
380
+ this.kill(run);
381
+ else
382
+ this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
383
+ }
384
+ return { runId, state: run.state };
385
+ }
386
+ status(raw) {
387
+ const o = (raw && typeof raw === "object" ? raw : {});
388
+ const run = this.get(String(o.runId ?? ""));
389
+ const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
390
+ // A page of at most 500 events; `lastSeq` is the cursor for the next page, so nothing is skipped.
391
+ const pending = run.events.filter((e) => e.seq > after);
392
+ const page = pending.length > 500 ? pending.slice(0, 500) : pending;
393
+ return {
394
+ runId: run.envelope.runId,
395
+ state: run.state,
396
+ ...(run.pause ? { pause: run.pause } : {}),
397
+ events: page,
398
+ lastSeq: page.length < pending.length ? page[page.length - 1].seq : run.seq,
399
+ ...(run.result ? { result: run.result } : {}),
400
+ };
401
+ }
402
+ /** Does this token belong to this run? The bridge forwarder's only credential. */
403
+ authorizeBridge(runId, token) {
404
+ const run = this.runs.get(runId);
405
+ return !!run && run.state !== "ended" && token.length > 0 && token === run.token;
406
+ }
407
+ async bridge(raw) {
408
+ const o = (raw && typeof raw === "object" ? raw : {});
409
+ const run = this.get(String(o.runId ?? ""));
410
+ if (!run.bridge || run.state === "ended")
411
+ throw new RunnerInputError("The run is not active", 409);
412
+ if (o.op === "list")
413
+ return { tools: await run.bridge.listTools() };
414
+ // A call that arrives while the run waits on the owner (the CLI gave up waiting on its own
415
+ // call) does not go ahead of them: nothing reaches the page until the owner resumes.
416
+ if (o.op === "call" && run.state === "paused")
417
+ return { content: [{ type: "text", text: `The run is paused for the owner (${run.pause?.reason.replace(/_/g, " ") ?? "waiting"}). Nothing can be done until they resume it. Stop now.` }], isError: true };
418
+ if (o.op === "call" && typeof o.name === "string")
419
+ return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
420
+ throw new RunnerInputError("op must be list or call");
421
+ }
422
+ get(runId) {
423
+ const run = this.runs.get(runId);
424
+ if (!run)
425
+ throw new RunnerInputError(`No application run ${runId} on this runner (it may have restarted)`, 404);
426
+ return run;
427
+ }
428
+ kill(run) {
429
+ const child = run.child;
430
+ if (!child || child.exitCode !== null)
431
+ return;
432
+ child.kill("SIGTERM");
433
+ setTimeout(() => {
434
+ if (child.exitCode === null)
435
+ child.kill("SIGKILL");
436
+ }, 5_000).unref?.();
437
+ }
438
+ end(run, r) {
439
+ if (run.state === "ended")
440
+ return;
441
+ if (run.state === "paused")
442
+ this.release(run, "stopped");
443
+ if (run.timer)
444
+ clearInterval(run.timer);
445
+ run.state = "ended";
446
+ run.endedAt = this.now();
447
+ const b = run.bridge;
448
+ run.result = {
449
+ runId: run.envelope.runId,
450
+ outcome: r.outcome,
451
+ mode: run.envelope.policy.mode,
452
+ traceId: run.envelope.runId,
453
+ engineAuth: r.engineAuth ?? run.engineAuth,
454
+ filled: b?.filled ?? 0,
455
+ uploaded: [...(b?.uploaded ?? [])],
456
+ submitAttempted: b?.submitAttempted ?? false,
457
+ summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
458
+ ...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
459
+ ...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
460
+ ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
461
+ };
462
+ if (run.child && run.child.exitCode === null)
463
+ this.kill(run);
464
+ void run.browser?.stop().catch(() => undefined);
465
+ }
466
+ /** Drop ended runs past retention, with their folders (scratch, MCP config, isolated profile). */
467
+ sweep() {
468
+ const cutoff = this.now() - this.retentionMs;
469
+ for (const [id, run] of this.runs) {
470
+ if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
471
+ rmSync(run.dir, { recursive: true, force: true });
472
+ this.runs.delete(id);
473
+ }
474
+ }
475
+ if (!existsSync(this.root))
476
+ return;
477
+ for (const instance of readdirSync(this.root)) {
478
+ const instDir = join(this.root, instance);
479
+ for (const runId of readdirSync(instDir)) {
480
+ if (this.runs.has(runId))
481
+ continue;
482
+ const dir = join(instDir, runId);
483
+ if (statSync(dir).mtimeMs < cutoff)
484
+ rmSync(dir, { recursive: true, force: true });
485
+ }
486
+ }
487
+ }
488
+ closeAll() {
489
+ for (const run of this.runs.values()) {
490
+ if (run.state !== "ended") {
491
+ run.cancelled = true;
492
+ this.release(run, "stopped");
493
+ this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
494
+ }
495
+ }
496
+ }
497
+ }
@@ -89,7 +89,7 @@ export function parseArtifactEnvelope(raw) {
89
89
  }
90
90
  const sha256 = (data) => createHash("sha256").update(data).digest("hex");
91
91
  /** A source file resolved on the real disk, strictly inside the workspace. */
92
- function resolveSource(workspace, rel) {
92
+ export function resolveSource(workspace, rel) {
93
93
  const real = realpathSync(resolve(workspace, rel));
94
94
  if (!real.startsWith(workspace + sep))
95
95
  throw new Error("outside the workspace");
@@ -115,6 +115,8 @@ export class BrowserBridge {
115
115
  findings = [];
116
116
  sourceFailures = [];
117
117
  summary = null;
118
+ /** Tool calls that reached the bridge. Zero at exit means the CLI never used the browser (#944). */
119
+ calls = 0;
118
120
  constructor(browser, host) {
119
121
  this.browser = browser;
120
122
  this.host = host;
@@ -128,6 +130,7 @@ export class BrowserBridge {
128
130
  ];
129
131
  }
130
132
  async callTool(name, args = {}) {
133
+ this.calls++;
131
134
  if (name === "record_finding")
132
135
  return this.recordFinding(args);
133
136
  if (name === "report_source_failure")
@@ -101,7 +101,7 @@ export function buildEngineSpec(input) {
101
101
  "-c",
102
102
  `${key}.default_tools_approval_mode="approve"`,
103
103
  "-c",
104
- `${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
104
+ `${key}.enabled_tools=[${(input.toolNames ?? BRIDGE_TOOL_NAMES).map(toml).join(", ")}]`,
105
105
  // Two spellings across Codex versions; an unknown key is ignored, so both are set.
106
106
  "-c",
107
107
  'web_search="disabled"',
@@ -143,6 +143,17 @@ export function signInHelp(engine) {
143
143
  ? "The Claude Code CLI on this machine is not signed in. Run `claude`, then /login, on that machine and start the run again."
144
144
  : "The Codex CLI on this machine is not signed in. Run `codex login` on that machine and start the run again.";
145
145
  }
146
+ /**
147
+ * Why a run that never touched the browser bridge failed, and the step that fixes it (#944). The usual
148
+ * cause is the CLI refusing the bridge's tools (an approval policy, #952) or not loading the server.
149
+ */
150
+ export function bridgeUnused(engine, said) {
151
+ const blocked = /approval|not (allowed|permitted)|permission|denied/i.test(said);
152
+ const cause = blocked
153
+ ? `The ${engine === "claude" ? "Claude Code" : "Codex"} CLI was refused the PAGS browser tools.`
154
+ : `The ${engine === "claude" ? "Claude Code" : "Codex"} CLI finished without using the PAGS browser tools.`;
155
+ return `${cause} No page was opened, so this run found nothing. Update the CLI on that machine (npm i -g @proagentstore/cli), restart \`pags up\`, and start the run again.`;
156
+ }
146
157
  /** The CLI's own closing text, from its structured output — the summary when it never called finish_research. */
147
158
  export function finalText(engine, lines) {
148
159
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -27,7 +27,7 @@ import { RunnerInputError } from "../errors.js";
27
27
  import { BrowserBridge, domainWithin } from "./bridge.js";
28
28
  import { BRIDGE_ENV } from "./bridge-stdio.js";
29
29
  import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
30
- import { buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
30
+ import { bridgeUnused, buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
31
31
  const MAX_EVENTS = 2000;
32
32
  const OUTPUT_LINES = 400;
33
33
  const SAFE_ID = /^[A-Za-z0-9_.:-]{1,100}$/;
@@ -387,6 +387,11 @@ export class LocalBrowserRuntime {
387
387
  const tail = run.output.slice(-5).join("\n").slice(-800);
388
388
  return { outcome: "failed", summary, error: `The ${e.engine} CLI exited with code ${code}${tail ? `: ${tail}` : ""}` };
389
389
  }
390
+ // A CLI that exits cleanly having never called the bridge did no research, whatever its prose
391
+ // says. Live run aac758dc (#952) ended `completed` with "the browser bridge required approval":
392
+ // no page opened, and the run looked like a success with nothing found.
393
+ if (!bridge?.calls)
394
+ return { outcome: "failed", summary, error: bridgeUnused(e.engine, summary) };
390
395
  return { outcome: "completed", summary };
391
396
  }
392
397
  end(run, r) {