@proagentstore/cli 0.4.129 → 0.4.131
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/crash-reports.js +287 -0
- package/dist/browser-runner/index.js +34 -12
- package/dist/browser-runner/local-apply/bridge.js +60 -1
- package/dist/browser-runner/local-apply/contract.js +22 -3
- package/dist/browser-runner/local-apply/live-run-browser.js +53 -10
- package/dist/browser-runner/local-apply/runtime.js +10 -4
- package/dist/browser-runner/local-apply/site-session.js +141 -0
- package/dist/browser-runner/request-failure.js +19 -0
- package/dist/browser-runner/runner.js +5 -1
- package/dist/browser-runner/server.js +15 -2
- package/dist/browser-runner/store.js +40 -17
- package/dist/index.js +321 -21
- package/package.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable crash reports for the local runner and the CLI that supervises it (#1087).
|
|
3
|
+
*
|
|
4
|
+
* Kept BYTE-IDENTICAL at
|
|
5
|
+
*
|
|
6
|
+
* packages/browser-runner/src/crash-reports.ts
|
|
7
|
+
* packages/cli/src/crash-reports.ts
|
|
8
|
+
*
|
|
9
|
+
* (`packages/cli/src/crash-reports.test.ts` fails the moment they differ) and imports only Node
|
|
10
|
+
* built-ins, so the CLI — which bundles the runner but cannot import its source — carries a copy.
|
|
11
|
+
*
|
|
12
|
+
* ── What it is for
|
|
13
|
+
*
|
|
14
|
+
* A runner crash used to be a line on stderr and nothing else: the runtime's `uncaughtException`
|
|
15
|
+
* handler printed and moved on, a request that threw answered 500 and was forgotten, and a runtime
|
|
16
|
+
* killed by a signal made `runner connect` exit with no record of the signal. This keeps a BOUNDED,
|
|
17
|
+
* SANITIZED record of each on disk — so it survives the process, an offline stretch and a restart —
|
|
18
|
+
* until the CLI, which holds the account token, has delivered it to the platform (`deliver.ts`
|
|
19
|
+
* there; `POST /v1/runner/crash-reports`, idempotent by `id`). There it is an `error_log` row that
|
|
20
|
+
* MCP `list_errors(source: "runner-crash")` reads.
|
|
21
|
+
*
|
|
22
|
+
* ── One writer per file
|
|
23
|
+
*
|
|
24
|
+
* The runtime writes `<dataDir>/crash-reports.json`; the CLI writes its own file. Nobody else writes
|
|
25
|
+
* either: the CLI records what it has DELIVERED in its own ledger rather than editing the runtime's
|
|
26
|
+
* file, so two processes never race on one file. A report delivered twice is harmless — the
|
|
27
|
+
* platform keeps one row per id.
|
|
28
|
+
*
|
|
29
|
+
* ── Atomic writes, and what they do not cover
|
|
30
|
+
*
|
|
31
|
+
* Every write goes to a temp file in the same directory, is fsync'd, then renamed over the target.
|
|
32
|
+
* A rename within one filesystem is atomic, so a reader sees the old file or the new one, never half
|
|
33
|
+
* of either; an interrupted write leaves a stray `*.tmp`, which is removed the next time the file is
|
|
34
|
+
* opened. LIMITS, stated rather than hidden:
|
|
35
|
+
* - a hard kill or power loss BEFORE the rename loses that one write (the file keeps its previous
|
|
36
|
+
* contents) — a crash that kills the process before `record` returns is not recorded;
|
|
37
|
+
* - the directory entry is not fsync'd, so a power loss just after the rename can still roll the
|
|
38
|
+
* file back to its previous version on some filesystems;
|
|
39
|
+
* - SIGKILL and an OOM kill run no handler in the dying process at all — only its PARENT can see
|
|
40
|
+
* the signal, which is why the CLI records `runner_exit` itself.
|
|
41
|
+
*
|
|
42
|
+
* ── Corruption is evidence, not an empty file
|
|
43
|
+
*
|
|
44
|
+
* A file that cannot be parsed is MOVED to `<file>.corrupt-<timestamp>` and reported, never read as
|
|
45
|
+
* empty and overwritten. Quarantined copies are bounded too ({@link MAX_QUARANTINED}).
|
|
46
|
+
*
|
|
47
|
+
* ── Bounds
|
|
48
|
+
*
|
|
49
|
+
* At most {@link MAX_REPORTS} reports per file (oldest dropped first, and COUNTED in `dropped`);
|
|
50
|
+
* a message is clipped to 1000 characters and a stack to 4000, marked as cut; credential-shaped
|
|
51
|
+
* text is masked. Metadata only (ADR 0004): no request or response bodies, no environment.
|
|
52
|
+
*/
|
|
53
|
+
import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
54
|
+
import { basename, dirname, join } from "node:path";
|
|
55
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
56
|
+
export const MAX_REPORTS = 100;
|
|
57
|
+
export const MAX_QUARANTINED = 5;
|
|
58
|
+
const MAX_MESSAGE = 1000;
|
|
59
|
+
const MAX_STACK = 4000;
|
|
60
|
+
/** Mask credential-shaped text: bearer tokens, API keys, JWTs, `token=…` pairs. */
|
|
61
|
+
export function maskSecrets(text) {
|
|
62
|
+
return text
|
|
63
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]")
|
|
64
|
+
.replace(/\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[abp]-[A-Za-z0-9-]{8,}|pags_runner_[A-Za-z0-9-]{8,})/g, "[redacted]")
|
|
65
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[redacted-jwt]")
|
|
66
|
+
.replace(/\b((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)("?)[^\s"&]{4,}\2/gi, "$1[redacted]");
|
|
67
|
+
}
|
|
68
|
+
/** A stable, non-identifying key for an account — the same as the runner lock's (`runner-lock.ts`). */
|
|
69
|
+
export function crashAccountKey(login) {
|
|
70
|
+
return createHash("sha256").update(login.trim().toLowerCase()).digest("hex").substring(0, 12);
|
|
71
|
+
}
|
|
72
|
+
/** Clip VISIBLY: a cut value says it was cut and by how much. */
|
|
73
|
+
function clip(text, max) {
|
|
74
|
+
if (text.length <= max)
|
|
75
|
+
return text;
|
|
76
|
+
return `${text.substring(0, max)}\n[cut: showing the first ${max} of ${text.length} characters]`;
|
|
77
|
+
}
|
|
78
|
+
const clean = (value, max) => clip(maskSecrets(String(value ?? "")), max);
|
|
79
|
+
const shortId = (value) => (typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : undefined);
|
|
80
|
+
/** The error's message, stack and cause, sanitized and bounded. */
|
|
81
|
+
export function describeError(error) {
|
|
82
|
+
if (error instanceof Error) {
|
|
83
|
+
const cause = error.cause;
|
|
84
|
+
return {
|
|
85
|
+
message: clean(`${error.name}: ${error.message}`, MAX_MESSAGE),
|
|
86
|
+
stack: error.stack ? clean(error.stack, MAX_STACK) : undefined,
|
|
87
|
+
cause: cause === undefined ? undefined : clean(cause instanceof Error ? `${cause.name}: ${cause.message}` : cause, MAX_MESSAGE),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { message: clean(error, MAX_MESSAGE) };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The runtime's own identity for its reports: the build and account `runner connect` passes it in
|
|
94
|
+
* `PAGS_RUNNER_BUILD` / `PAGS_RUNNER_ACCOUNT`. Absent for a bare `pags runner start`.
|
|
95
|
+
*/
|
|
96
|
+
export function runtimeCrashIdentity(env = process.env) {
|
|
97
|
+
return { source: "runtime", build: env.PAGS_RUNNER_BUILD || undefined, account: env.PAGS_RUNNER_ACCOUNT || undefined };
|
|
98
|
+
}
|
|
99
|
+
/** Build a report from what is known — every field bounded, ids id-shaped or dropped. */
|
|
100
|
+
export function crashReport(input) {
|
|
101
|
+
return {
|
|
102
|
+
id: shortId(input.id) ?? `crash_${randomUUID()}`,
|
|
103
|
+
at: input.at ?? new Date().toISOString(),
|
|
104
|
+
source: input.source,
|
|
105
|
+
kind: input.kind,
|
|
106
|
+
message: clean(input.message, MAX_MESSAGE),
|
|
107
|
+
...(input.stack ? { stack: clean(input.stack, MAX_STACK) } : {}),
|
|
108
|
+
...(input.cause ? { cause: clean(input.cause, MAX_MESSAGE) } : {}),
|
|
109
|
+
...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
|
|
110
|
+
...(input.signal !== undefined ? { signal: input.signal } : {}),
|
|
111
|
+
...(shortId(input.build) ? { build: input.build } : {}),
|
|
112
|
+
...(shortId(input.account) ? { account: input.account } : {}),
|
|
113
|
+
...(shortId(input.instanceId) ? { instanceId: input.instanceId } : {}),
|
|
114
|
+
...(shortId(input.sessionId) ? { sessionId: input.sessionId } : {}),
|
|
115
|
+
...(shortId(input.taskId) ? { taskId: input.taskId } : {}),
|
|
116
|
+
...(input.route ? { route: clean(input.route, 200) } : {}),
|
|
117
|
+
...(input.tail?.length ? { tail: input.tail.slice(-20).map((line) => clean(line, 300)) } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Write `data` as JSON atomically: temp file in the same directory, fsync, rename. Throws on
|
|
122
|
+
* failure — the caller decides whether that may surface.
|
|
123
|
+
*/
|
|
124
|
+
export function writeJsonAtomic(path, data, mode = 0o600) {
|
|
125
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
126
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
127
|
+
const fd = openSync(tmp, "w", mode);
|
|
128
|
+
try {
|
|
129
|
+
writeSync(fd, `${JSON.stringify(data, null, 2)}\n`);
|
|
130
|
+
fsyncSync(fd);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
renameSync(tmp, path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(tmp);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// The temp file is already gone; the rename's own error is the one that matters.
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Remove temp files an interrupted {@link writeJsonAtomic} left beside `path`. */
|
|
149
|
+
export function removeStaleTemps(path) {
|
|
150
|
+
const dir = dirname(path);
|
|
151
|
+
const prefix = `${basename(path)}.`;
|
|
152
|
+
if (!existsSync(dir))
|
|
153
|
+
return;
|
|
154
|
+
for (const name of readdirSync(dir)) {
|
|
155
|
+
if (!name.startsWith(prefix) || !name.endsWith(".tmp"))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(join(dir, name));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Another process may own it; a temp file is never read, so leaving it is safe.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Read a JSON file. A file that does not parse is MOVED aside to `<path>.corrupt-<timestamp>` —
|
|
167
|
+
* kept as evidence, and out of the way so the next write cannot overwrite it — and reported as
|
|
168
|
+
* `quarantined`. At most {@link MAX_QUARANTINED} quarantined copies are kept per file.
|
|
169
|
+
*/
|
|
170
|
+
export function readJsonOrQuarantine(path) {
|
|
171
|
+
removeStaleTemps(path);
|
|
172
|
+
if (!existsSync(path))
|
|
173
|
+
return { status: "missing" };
|
|
174
|
+
try {
|
|
175
|
+
return { status: "ok", value: JSON.parse(readFileSync(path, "utf-8")) };
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const quarantinedTo = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
179
|
+
try {
|
|
180
|
+
renameSync(path, quarantinedTo);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Could not move it: keep a COPY instead, so the caller's next write cannot destroy the
|
|
184
|
+
// only evidence. If even that fails the report says so.
|
|
185
|
+
try {
|
|
186
|
+
copyFileSync(path, quarantinedTo);
|
|
187
|
+
}
|
|
188
|
+
catch (copyError) {
|
|
189
|
+
return { status: "quarantined", quarantinedTo: "(not preserved)", error: `${describeError(error).message}; preserving it failed: ${describeError(copyError).message}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
pruneQuarantine(path);
|
|
193
|
+
return { status: "quarantined", quarantinedTo, error: describeError(error).message };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function pruneQuarantine(path) {
|
|
197
|
+
const dir = dirname(path);
|
|
198
|
+
const prefix = `${basename(path)}.corrupt-`;
|
|
199
|
+
const copies = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
|
|
200
|
+
const excess = copies.length - MAX_QUARANTINED;
|
|
201
|
+
for (const name of excess > 0 ? copies.slice(0, excess) : []) {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(join(dir, name));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Best-effort pruning; an extra copy is a bounded cost.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* One process's crash-report file. Synchronous throughout: it is called from `uncaughtException`
|
|
212
|
+
* and from an `exit` listener, where nothing asynchronous is guaranteed to run.
|
|
213
|
+
*/
|
|
214
|
+
export class CrashLog {
|
|
215
|
+
path;
|
|
216
|
+
base;
|
|
217
|
+
/** `base` stamps the reports this log writes about ITSELF (a quarantined file). */
|
|
218
|
+
constructor(path, base = { source: "runtime" }) {
|
|
219
|
+
this.path = path;
|
|
220
|
+
this.base = base;
|
|
221
|
+
}
|
|
222
|
+
/** Everything still on disk, oldest first. A corrupt file is quarantined and reported, then read as a fresh file. */
|
|
223
|
+
read() {
|
|
224
|
+
const got = readJsonOrQuarantine(this.path);
|
|
225
|
+
if (got.status === "ok") {
|
|
226
|
+
const reports = Array.isArray(got.value.reports) ? got.value.reports.filter((r) => !!r && typeof r.id === "string") : [];
|
|
227
|
+
return { reports, dropped: Number(got.value.dropped) || 0 };
|
|
228
|
+
}
|
|
229
|
+
if (got.status === "missing")
|
|
230
|
+
return { reports: [], dropped: 0 };
|
|
231
|
+
// The quarantine itself is a report in the fresh file, so the evidence is pointed at, not lost.
|
|
232
|
+
const file = {
|
|
233
|
+
reports: [crashReport({ ...this.base, kind: "store_corrupt", message: `crash-report file was unreadable and was moved to ${basename(got.quarantinedTo)}: ${got.error}` })],
|
|
234
|
+
dropped: 0,
|
|
235
|
+
};
|
|
236
|
+
this.write(file);
|
|
237
|
+
return file;
|
|
238
|
+
}
|
|
239
|
+
/** Append a report (bounded). Never throws: a crash path must not crash on its own record. */
|
|
240
|
+
record(report) {
|
|
241
|
+
try {
|
|
242
|
+
const file = this.read();
|
|
243
|
+
if (file.reports.some((r) => r.id === report.id))
|
|
244
|
+
return true;
|
|
245
|
+
file.reports.push(report);
|
|
246
|
+
const over = file.reports.length - MAX_REPORTS;
|
|
247
|
+
if (over > 0) {
|
|
248
|
+
file.reports = file.reports.slice(over);
|
|
249
|
+
file.dropped += over;
|
|
250
|
+
}
|
|
251
|
+
this.write(file);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
process.stderr.write(`[crash-reports] could not record ${report.kind}: ${describeError(error).message}\n`);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Drop reports by id — only by this file's own writer. */
|
|
260
|
+
remove(ids) {
|
|
261
|
+
if (!ids.length)
|
|
262
|
+
return;
|
|
263
|
+
const file = this.read();
|
|
264
|
+
const gone = new Set(ids);
|
|
265
|
+
file.reports = file.reports.filter((r) => !gone.has(r.id));
|
|
266
|
+
this.write(file);
|
|
267
|
+
}
|
|
268
|
+
write(file) {
|
|
269
|
+
writeJsonAtomic(this.path, file);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Record process-level crashes in `log`. The process keeps running afterwards (a runner that died on
|
|
274
|
+
* every stray error would drop its relay), so `onCrash` is where a caller adds its own line.
|
|
275
|
+
*/
|
|
276
|
+
export function installCrashHandlers(log, base, onCrash) {
|
|
277
|
+
process.on("uncaughtException", (error) => {
|
|
278
|
+
const report = crashReport({ ...base, kind: "uncaught_exception", ...describeError(error) });
|
|
279
|
+
log.record(report);
|
|
280
|
+
onCrash?.(report);
|
|
281
|
+
});
|
|
282
|
+
process.on("unhandledRejection", (reason) => {
|
|
283
|
+
const report = crashReport({ ...base, kind: "unhandled_rejection", ...describeError(reason) });
|
|
284
|
+
log.record(report);
|
|
285
|
+
onCrash?.(report);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { CrashLog, crashReport, describeError, installCrashHandlers, runtimeCrashIdentity } from "./crash-reports.js";
|
|
4
5
|
import { startRunnerServer } from "./server.js";
|
|
5
6
|
import { reapOnStartup } from "./reaper.js";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
// Resilience: a stray error in any runtime must NOT take the whole runner down —
|
|
8
|
-
// that drops the tunnel and forces the user to restart `pags up` (and lose their
|
|
9
|
-
// session). Log it and keep serving. Per-component handlers catch most things;
|
|
10
|
-
// this is the last-resort backstop.
|
|
11
|
-
process.on("uncaughtException", (err) => {
|
|
12
|
-
process.stderr.write(`[runner] uncaught: ${err?.stack || String(err)}\n`);
|
|
13
|
-
});
|
|
14
|
-
process.on("unhandledRejection", (reason) => {
|
|
15
|
-
process.stderr.write(`[runner] unhandled rejection: ${String(reason)}\n`);
|
|
16
|
-
});
|
|
17
8
|
function arg(name, fallback) {
|
|
18
9
|
const index = process.argv.indexOf(name);
|
|
19
10
|
if (index === -1)
|
|
@@ -67,6 +58,18 @@ Endpoints:
|
|
|
67
58
|
process.exit(0);
|
|
68
59
|
}
|
|
69
60
|
const config = configFromArgs();
|
|
61
|
+
// Resilience: a stray error in any runtime must NOT take the whole runner down —
|
|
62
|
+
// that drops the tunnel and forces the user to restart `pags up` (and lose their
|
|
63
|
+
// session). Keep serving. Per-component handlers catch most things; this is the
|
|
64
|
+
// last-resort backstop — and since #1087 it leaves a DURABLE record, which the CLI
|
|
65
|
+
// delivers to the platform (MCP `list_errors`), instead of a stderr line only.
|
|
66
|
+
// Build and account come from `runner connect` (`runtimeCrashIdentity`); the CLI delivers a report
|
|
67
|
+
// only under the account that wrote it.
|
|
68
|
+
const IDENTITY = runtimeCrashIdentity();
|
|
69
|
+
const crashLog = new CrashLog(join(config.dataDir, "crash-reports.json"), IDENTITY);
|
|
70
|
+
installCrashHandlers(crashLog, IDENTITY, (report) => {
|
|
71
|
+
process.stderr.write(`[runner] ${report.kind === "uncaught_exception" ? "uncaught" : "unhandled rejection"}: ${report.stack || report.message}\n`);
|
|
72
|
+
});
|
|
70
73
|
// SECURITY: the runner's endpoints spawn CLIs and drive a browser as the user.
|
|
71
74
|
// Without a token, `authorize()` allows every request, so a tokenless runner must
|
|
72
75
|
// never be reachable off the local machine. Refuse to start if bound to a
|
|
@@ -83,8 +86,27 @@ if (!config.token && !LOOPBACK.has(config.host)) {
|
|
|
83
86
|
// machine to load 253. Nothing in-process can clean those up, so we do it here,
|
|
84
87
|
// before we start competing for the same CPU. Narrow by construction: see the
|
|
85
88
|
// SAFETY block in reaper.ts for why the user's real Chrome can never match.
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
/** A failure before the runtime serves anything: recorded, said, and the process exits non-zero. */
|
|
90
|
+
function startupFailed(stage, error) {
|
|
91
|
+
const report = crashReport({ ...IDENTITY, kind: "startup_failure", instanceId: config.instanceId, ...describeError(error), route: stage });
|
|
92
|
+
crashLog.record(report);
|
|
93
|
+
process.stderr.write(`[runner] could not start (${stage}): ${report.message}\n`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
reapOnStartup((line) => process.stderr.write(`${line}\n`));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// The reaper is housekeeping: its failure is recorded, and the runtime still starts.
|
|
101
|
+
crashLog.record(crashReport({ ...IDENTITY, kind: "startup_failure", ...describeError(error), route: "reap" }));
|
|
102
|
+
}
|
|
103
|
+
let started;
|
|
104
|
+
try {
|
|
105
|
+
started = await startRunnerServer(config);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
startupFailed("listen", error);
|
|
109
|
+
}
|
|
88
110
|
process.stdout.write(`ProAgentStore browser runtime listening at ${started.url}\n`);
|
|
89
111
|
process.stdout.write(`Data dir: ${config.dataDir}\n`);
|
|
90
112
|
process.stdout.write("Control plane: PAGS; runtime plane: PAGS; brain placement: PAGS; runner role: tool-executor\n");
|
|
@@ -30,6 +30,7 @@ import { CONFIRMATION_INTERVAL_SECONDS, CONFIRMATION_LOOKS, CONFIRMATION_TEXT_RE
|
|
|
30
30
|
import { ELEMENT_PROBE_FN } from "../commit-guard.js";
|
|
31
31
|
import { normalize } from "../local-artifact/engine.js";
|
|
32
32
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
33
|
+
import { looksLikeSignIn, siteKeyOf } from "./site-session.js";
|
|
33
34
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
|
|
34
35
|
const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
|
|
35
36
|
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review", "supervisor_checkpoint"];
|
|
@@ -314,10 +315,16 @@ export class ApplyBridge {
|
|
|
314
315
|
if (!blocker)
|
|
315
316
|
return text(toolText);
|
|
316
317
|
this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: blocker } });
|
|
318
|
+
const site = blocker === "login_required" && host ? siteKeyOf(host) : null;
|
|
319
|
+
if (site)
|
|
320
|
+
await this.host.sessions?.beginSignIn(site).catch(() => undefined);
|
|
317
321
|
const ok = await this.pauseFor({ reason: blocker, url: state.url, domain: host }, async () => {
|
|
318
322
|
const after = await this.inspect();
|
|
319
323
|
return !!after && this.blockerOf(after) === null;
|
|
320
324
|
});
|
|
325
|
+
// A sign-in the owner completed here is remembered for this browser profile (#1094).
|
|
326
|
+
if (ok && site)
|
|
327
|
+
await this.recordSignIn(site);
|
|
321
328
|
if (!ok)
|
|
322
329
|
return text(`${host} needs a person (${blocker.replace(/_/g, " ")}) and it was not resolved. Do not work around it. Stop now.`, true);
|
|
323
330
|
return text("Resumed after the owner handled it. Take a browser_snapshot to read the page.");
|
|
@@ -664,6 +671,9 @@ export class ApplyBridge {
|
|
|
664
671
|
const domain = hostOf(before.url) ?? "";
|
|
665
672
|
if (!this.host.isAllowed(domain))
|
|
666
673
|
return this.refuse("browser_click", "submit", `${domain} is not one of this application's sites; nothing was submitted.`, domain);
|
|
674
|
+
const gate = await this.sessionGate(before.url, domain);
|
|
675
|
+
if (gate)
|
|
676
|
+
return gate;
|
|
667
677
|
this.submitAttempted = true;
|
|
668
678
|
this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
|
|
669
679
|
const res = await this.browser.callTool("browser_click", forward);
|
|
@@ -681,7 +691,19 @@ export class ApplyBridge {
|
|
|
681
691
|
this.host.emit({ type: "submit.confirmed", url: at.url, domain: hostOf(at.url) ?? domain, detail: { gateId: this.host.gateId, marker: seen.marker, ...evidence } });
|
|
682
692
|
return text("Submitted, and the site confirmed it. Stop now.");
|
|
683
693
|
}
|
|
684
|
-
|
|
694
|
+
// Landed on a sign-in page (#1094): the outcome stays `submit_unconfirmed` (#1013 never infers
|
|
695
|
+
// "not sent" from a redirect), but this profile now knows the site needs a sign-in, so the next
|
|
696
|
+
// run asks the owner to sign in BEFORE pressing instead of bouncing again.
|
|
697
|
+
const wall = after && looksLikeSignIn(after.url, after.login) ? this.host.sessions?.learnSignInWall(before.url, after.url) : null;
|
|
698
|
+
if (wall)
|
|
699
|
+
this.host.emit({ type: "policy.decision", domain, detail: { tool: "browser_click", class: "session", decision: "learned", reason: "bounced_to_sign_in", site: wall } });
|
|
700
|
+
this.blocked = {
|
|
701
|
+
reason: "submit_unconfirmed",
|
|
702
|
+
questions: [
|
|
703
|
+
"The application was submitted but the site did not confirm it. Check the employer's site or your email before anything is retried." +
|
|
704
|
+
(wall ? ` The site asked for a sign-in, so the next run on this machine will ask you to sign in to ${wall} first.` : ""),
|
|
705
|
+
],
|
|
706
|
+
};
|
|
685
707
|
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId, ...evidence } });
|
|
686
708
|
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
687
709
|
}
|
|
@@ -705,6 +727,43 @@ export class ApplyBridge {
|
|
|
705
727
|
}
|
|
706
728
|
return { after, looks: CONFIRMATION_LOOKS };
|
|
707
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* Before an uncertain press (#1094): is this browser profile signed in to the site? If the ledger
|
|
732
|
+
* says it is not, or its session cookies are gone, the run pauses `login_required` so the owner
|
|
733
|
+
* signs in ONCE in this same browser (the existing handoff) — and nothing has been pressed, so
|
|
734
|
+
* there is no submit to be uncertain about. Returns null when the press may go ahead.
|
|
735
|
+
*/
|
|
736
|
+
async sessionGate(url, domain) {
|
|
737
|
+
const ledger = this.host.sessions;
|
|
738
|
+
if (!ledger)
|
|
739
|
+
return null;
|
|
740
|
+
const check = await ledger.check(url).catch(() => null);
|
|
741
|
+
if (!check || check.state === "ok")
|
|
742
|
+
return null;
|
|
743
|
+
const site = check.site;
|
|
744
|
+
this.host.emit({ type: "policy.decision", domain, detail: { tool: "browser_click", class: "session", decision: "paused", reason: `session_${check.reason}`, site } });
|
|
745
|
+
await ledger.beginSignIn(site).catch(() => undefined);
|
|
746
|
+
const ok = await this.pauseFor({
|
|
747
|
+
reason: "login_required",
|
|
748
|
+
url,
|
|
749
|
+
domain,
|
|
750
|
+
question: `Sign in to ${site} in this browser. It is remembered on this machine, so later applications reuse it. Nothing has been submitted.`,
|
|
751
|
+
});
|
|
752
|
+
if (!ok) {
|
|
753
|
+
this.blocked = { reason: "login_required", questions: [`Sign in to ${site} on this machine's application browser, then retry. Nothing was submitted.`] };
|
|
754
|
+
return this.refuse("browser_click", "submit", `${site} needs a sign-in in this browser and the owner did not complete one. Nothing was submitted. Stop now.`, domain);
|
|
755
|
+
}
|
|
756
|
+
await this.recordSignIn(site);
|
|
757
|
+
return text(`The owner signed in to ${site}; this browser remembers it. Nothing has been submitted. Open the job page again with browser_navigate (${url.split(/[?#]/, 1)[0]}), take a browser_snapshot, and continue from there.`);
|
|
758
|
+
}
|
|
759
|
+
async recordSignIn(site) {
|
|
760
|
+
const ledger = this.host.sessions;
|
|
761
|
+
if (!ledger)
|
|
762
|
+
return;
|
|
763
|
+
const result = await ledger.completeSignIn(site).catch(() => null);
|
|
764
|
+
if (result)
|
|
765
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "sign_in", class: "session", decision: "recorded", site, count: result.fingerprint, basis: result.verifiable ? "cookies" : "unverifiable" } });
|
|
766
|
+
}
|
|
708
767
|
/** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
|
|
709
768
|
requireSupervisor(tool, actionClass) {
|
|
710
769
|
if (this.supervisorApproved)
|
|
@@ -179,7 +179,20 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
|
179
179
|
// from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
|
|
180
180
|
// Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
|
|
181
181
|
// text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
|
|
182
|
-
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded", "marker", "looks", "urlChanged", "titleChanged", "shot", "point"]);
|
|
182
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded", "marker", "looks", "urlChanged", "titleChanged", "shot", "point", "site"]);
|
|
183
|
+
/**
|
|
184
|
+
* A page URL as a trace may keep it (#1069): origin and path, no query or fragment — a sign-in page's
|
|
185
|
+
* carry OAuth state, PKCE values and session ids. "" when it is not an http(s) URL.
|
|
186
|
+
*/
|
|
187
|
+
export function traceUrl(raw) {
|
|
188
|
+
try {
|
|
189
|
+
const u = new URL(raw);
|
|
190
|
+
return u.protocol === "http:" || u.protocol === "https:" ? `${u.origin}${u.pathname}` : "";
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
183
196
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
184
197
|
export function parseLocalApplyEvent(raw) {
|
|
185
198
|
if (!raw || typeof raw !== "object")
|
|
@@ -190,9 +203,15 @@ export function parseLocalApplyEvent(raw) {
|
|
|
190
203
|
if (!type || !at || Number.isNaN(Date.parse(at)))
|
|
191
204
|
return null;
|
|
192
205
|
const event = { type, at };
|
|
206
|
+
// Origin and path only (#1069): a sign-in page's query and fragment carry OAuth state and session
|
|
207
|
+
// ids, and the trace is read back verbatim by `application_run` and the console. Same rule as the
|
|
208
|
+
// #1063 artifact captions, so an older runner that still sends the full URL is covered here too.
|
|
193
209
|
const url = str(o.url, 2000);
|
|
194
|
-
if (url && isHttpUrl(url))
|
|
195
|
-
|
|
210
|
+
if (url && isHttpUrl(url)) {
|
|
211
|
+
const kept = traceUrl(url);
|
|
212
|
+
if (kept)
|
|
213
|
+
event.url = kept;
|
|
214
|
+
}
|
|
196
215
|
const domain = str(o.domain, 253);
|
|
197
216
|
if (domain)
|
|
198
217
|
event.domain = domain.toLowerCase();
|
|
@@ -1,18 +1,38 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { McpRuntime } from "../mcp-runtime.js";
|
|
4
|
+
import { SiteSessionLedger } from "./site-session.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where an application profile lives on this machine (#1094) — documented in
|
|
7
|
+
* `APPLICATION_PROFILES.md` so the owner can find and inspect it:
|
|
8
|
+
*
|
|
9
|
+
* <dataDir>/application-profiles/<profile>/site-sessions.json which sites it is signed in to (no values)
|
|
10
|
+
* <dataDir>/application-profiles/isolated/browser/ the `isolated` profile's browser data
|
|
11
|
+
*
|
|
12
|
+
* The `default` profile's browser data is the runner's own browser (`<dataDir>/chrome-profile`, or
|
|
13
|
+
* the private real-profile copy), which already persisted; only its ledger lives here.
|
|
14
|
+
*/
|
|
15
|
+
export function applicationProfileDir(dataDir, profile) {
|
|
16
|
+
return join(dataDir, "application-profiles", profile);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One run at a time per on-disk profile. Chrome locks a user-data-dir to one process, so a second
|
|
20
|
+
* launch on the same directory would fail obscurely; this says what is happening instead.
|
|
21
|
+
*/
|
|
22
|
+
const inUse = new Set();
|
|
4
23
|
/** Bind application handoff to the run browser, not to the runner-wide active page. */
|
|
5
24
|
export function createLocalApplyBrowserFactory(options) {
|
|
6
25
|
return async (profile, runDir) => {
|
|
26
|
+
const ledgerPath = join(applicationProfileDir(options.dataDir, profile), "site-sessions.json");
|
|
7
27
|
if (profile === "isolated")
|
|
8
|
-
return
|
|
28
|
+
return createPersistentApplyBrowser(join(applicationProfileDir(options.dataDir, profile), "browser"), ledgerPath, options.headless);
|
|
9
29
|
const browser = await options.defaultBrowser(profile, runDir);
|
|
10
|
-
return { ...browser, handoffPage: options.sharedPage };
|
|
30
|
+
return { ...browser, handoffPage: options.sharedPage, sessions: new SiteSessionLedger(ledgerPath, options.defaultCookies) };
|
|
11
31
|
};
|
|
12
32
|
}
|
|
13
33
|
/**
|
|
14
34
|
* Keep local-apply's browser construction out of `runner.ts`: research uses the generic factory,
|
|
15
|
-
* while application runs get a page-owning
|
|
35
|
+
* while application runs get a page-owning persistent context for the scoped handoff.
|
|
16
36
|
*/
|
|
17
37
|
export function createRunnerBrowserFactories(options) {
|
|
18
38
|
const browserFor = async (profile, runDir) => {
|
|
@@ -22,21 +42,40 @@ export function createRunnerBrowserFactories(options) {
|
|
|
22
42
|
await mcp.start({ userDataDir: join(runDir, "profile"), headless: options.headless });
|
|
23
43
|
return { tools: mcp, stop: () => mcp.stop() };
|
|
24
44
|
};
|
|
25
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
browserFor,
|
|
47
|
+
applyBrowserFor: createLocalApplyBrowserFactory({
|
|
48
|
+
headless: options.headless,
|
|
49
|
+
dataDir: options.dataDir,
|
|
50
|
+
defaultBrowser: browserFor,
|
|
51
|
+
defaultCookies: options.defaultCookies,
|
|
52
|
+
sharedPage: options.sharedPage,
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
26
55
|
}
|
|
27
56
|
/**
|
|
28
|
-
* Start the
|
|
29
|
-
* page for the bounded takeover adapter.
|
|
30
|
-
*
|
|
57
|
+
* Start the application browser on this machine's PERSISTENT `isolated` profile (#1094) and retain
|
|
58
|
+
* only its current live page for the bounded takeover adapter.
|
|
59
|
+
*
|
|
60
|
+
* Until #1094 this was the run's throwaway `runDir/profile`, deleted with the run, so a sign-in the
|
|
61
|
+
* owner completed in one run was gone by the next and every SEEK run bounced to its sign-in page.
|
|
62
|
+
* The profile is still never seeded from, copied to, or shared with the owner's everyday browser: it
|
|
63
|
+
* is a dedicated application browser that now remembers its own sign-ins, like a normal browser.
|
|
31
64
|
*/
|
|
32
|
-
export async function
|
|
65
|
+
export async function createPersistentApplyBrowser(profileDir, ledgerPath, headless) {
|
|
66
|
+
if (inUse.has(profileDir))
|
|
67
|
+
throw new Error("This machine's application browser profile is in use by another run; try again when it ends.");
|
|
68
|
+
inUse.add(profileDir);
|
|
33
69
|
const playwright = await import("playwright");
|
|
34
|
-
const profileDir = join(runDir, "profile");
|
|
35
70
|
let context;
|
|
36
71
|
let mcp;
|
|
37
72
|
let activePage = null;
|
|
38
73
|
let stopped = false;
|
|
39
74
|
try {
|
|
75
|
+
mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
|
76
|
+
// A persistent profile keeps the LAST launch's port file; read before Chrome rewrites it, the
|
|
77
|
+
// tools would attach to a dead port. Chrome writes a fresh one on every start.
|
|
78
|
+
rmSync(join(profileDir, "DevToolsActivePort"), { force: true });
|
|
40
79
|
context = await playwright.chromium.launchPersistentContext(profileDir, {
|
|
41
80
|
headless,
|
|
42
81
|
acceptDownloads: true,
|
|
@@ -67,9 +106,11 @@ export async function createIsolatedApplyBrowser(runDir, headless) {
|
|
|
67
106
|
track(initial);
|
|
68
107
|
mcp = new McpRuntime();
|
|
69
108
|
await mcp.start({ cdpEndpoint: await cdpEndpoint(profileDir) });
|
|
109
|
+
const ctx = context;
|
|
70
110
|
return {
|
|
71
111
|
tools: mcp,
|
|
72
112
|
handoffPage: () => activePage && !activePage.isClosed() ? activePage : null,
|
|
113
|
+
sessions: new SiteSessionLedger(ledgerPath, async () => (await ctx.cookies()).map((c) => ({ name: c.name, domain: c.domain, expires: c.expires }))),
|
|
73
114
|
stop: async () => {
|
|
74
115
|
if (stopped)
|
|
75
116
|
return;
|
|
@@ -77,12 +118,14 @@ export async function createIsolatedApplyBrowser(runDir, headless) {
|
|
|
77
118
|
activePage = null;
|
|
78
119
|
await mcp.stop().catch(() => undefined);
|
|
79
120
|
await context.close().catch(() => undefined);
|
|
121
|
+
inUse.delete(profileDir);
|
|
80
122
|
},
|
|
81
123
|
};
|
|
82
124
|
}
|
|
83
125
|
catch (error) {
|
|
84
126
|
await mcp?.stop().catch(() => undefined);
|
|
85
127
|
await context?.close().catch(() => undefined);
|
|
128
|
+
inUse.delete(profileDir);
|
|
86
129
|
throw error;
|
|
87
130
|
}
|
|
88
131
|
}
|
|
@@ -37,7 +37,7 @@ import { ApplyBridge } from "./bridge.js";
|
|
|
37
37
|
import { APPLY_SHOT_CAP, ShotRecorder } from "../local-browser/replay.js";
|
|
38
38
|
import { confirmationSignals } from "./confirmation.js";
|
|
39
39
|
import { listingUnavailableReason } from "./unavailable.js";
|
|
40
|
-
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
|
|
40
|
+
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, traceUrl, } from "./contract.js";
|
|
41
41
|
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
42
42
|
import { LocalApplyHandoffRuntime } from "./runtime-handoff.js";
|
|
43
43
|
const MAX_EVENTS = 1000;
|
|
@@ -397,6 +397,7 @@ export class LocalApplyRuntime {
|
|
|
397
397
|
limits: e.limits,
|
|
398
398
|
mode: e.policy.mode,
|
|
399
399
|
gateId: e.policy.submitGate?.gateId,
|
|
400
|
+
sessions: run.browser.sessions,
|
|
400
401
|
}, e.policy.screenshots === false ? undefined : run.shots);
|
|
401
402
|
if (spec.mcpConfig)
|
|
402
403
|
writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
|
|
@@ -527,7 +528,10 @@ export class LocalApplyRuntime {
|
|
|
527
528
|
emit(run, ev) {
|
|
528
529
|
if (run.events.length >= MAX_EVENTS)
|
|
529
530
|
return;
|
|
530
|
-
|
|
531
|
+
// Origin and path only (#1069): a sign-in page's query carries OAuth state and session ids.
|
|
532
|
+
const url = ev.url ? traceUrl(ev.url) : "";
|
|
533
|
+
const { url: _raw, ...rest } = ev;
|
|
534
|
+
run.events.push({ ...rest, ...(url ? { url } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
531
535
|
}
|
|
532
536
|
pause(run, p) {
|
|
533
537
|
if (run.state === "ended" || run.cancelled)
|
|
@@ -535,8 +539,10 @@ export class LocalApplyRuntime {
|
|
|
535
539
|
if (run.state === "running") {
|
|
536
540
|
run.activeMs += this.now() - run.activeSince;
|
|
537
541
|
run.state = "paused";
|
|
538
|
-
|
|
539
|
-
|
|
542
|
+
const url = p.url ? traceUrl(p.url) : "";
|
|
543
|
+
const { url: _raw, ...rest } = p;
|
|
544
|
+
run.pause = { ...rest, ...(url ? { url } : {}) };
|
|
545
|
+
this.emit(run, { type: "run.paused", pauseReason: p.reason, ...(url ? { url } : {}), ...(p.domain ? { domain: p.domain } : {}) });
|
|
540
546
|
}
|
|
541
547
|
return new Promise((done) => run.waiters.push(done));
|
|
542
548
|
}
|