@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,141 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Sites whose one-click apply only works for a signed-in user. SEEK is listed from the verified
|
|
5
|
+
* failure in #1094 (two fill runs, two machines, both bounced to `login.seek.com`). A site not listed
|
|
6
|
+
* here joins the ledger the first time a press bounces to its sign-in page.
|
|
7
|
+
*/
|
|
8
|
+
export const SIGN_IN_REQUIRED_SITES = ["seek.com", "seek.com.au", "seek.co.nz"];
|
|
9
|
+
const SECOND_LEVEL = new Set(["com", "co", "net", "org", "gov", "edu", "ac", "id"]);
|
|
10
|
+
/** `au.seek.com` → `seek.com`; `www.seek.com.au` → `seek.com.au`. Good enough for a site key, not a PSL. */
|
|
11
|
+
export function siteKeyOf(host) {
|
|
12
|
+
const labels = host.toLowerCase().replace(/\.$/, "").split(".").filter(Boolean);
|
|
13
|
+
if (labels.length <= 2)
|
|
14
|
+
return labels.join(".");
|
|
15
|
+
const tld = labels[labels.length - 1];
|
|
16
|
+
const sld = labels[labels.length - 2];
|
|
17
|
+
return labels.slice(tld.length === 2 && SECOND_LEVEL.has(sld) ? -3 : -2).join(".");
|
|
18
|
+
}
|
|
19
|
+
/** Does this page look like a sign-in page? A password field, or a login/SSO host or path. */
|
|
20
|
+
export function looksLikeSignIn(url, hasPassword) {
|
|
21
|
+
if (hasPassword)
|
|
22
|
+
return true;
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
return /^(login|signin|sign-in|auth|accounts?|sso|id)\./i.test(u.hostname) || /\/(login|log-in|signin|sign-in|oauth2?|authorize)(\/|$)/i.test(u.pathname);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function cookieSite(c) {
|
|
32
|
+
return siteKeyOf(c.domain.replace(/^\./, ""));
|
|
33
|
+
}
|
|
34
|
+
function live(c, nowMs) {
|
|
35
|
+
return c.expires === -1 || c.expires * 1000 > nowMs;
|
|
36
|
+
}
|
|
37
|
+
export class SiteSessionLedger {
|
|
38
|
+
path;
|
|
39
|
+
cookies;
|
|
40
|
+
now;
|
|
41
|
+
file;
|
|
42
|
+
/** Cookie names (with expiry) seen per site when a sign-in pause began, to diff on resume. */
|
|
43
|
+
before = new Map();
|
|
44
|
+
constructor(path, cookies, now = Date.now) {
|
|
45
|
+
this.path = path;
|
|
46
|
+
this.cookies = cookies;
|
|
47
|
+
this.now = now;
|
|
48
|
+
this.file = SiteSessionLedger.read(path);
|
|
49
|
+
}
|
|
50
|
+
static read(path) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
53
|
+
if (parsed && parsed.version === 1 && parsed.sites && typeof parsed.sites === "object")
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Missing or unreadable: start empty. The worst case is one extra sign-in prompt.
|
|
58
|
+
}
|
|
59
|
+
return { version: 1, sites: {} };
|
|
60
|
+
}
|
|
61
|
+
entries() {
|
|
62
|
+
return Object.values(this.file.sites);
|
|
63
|
+
}
|
|
64
|
+
/** Is this profile signed in to the site that `url` is on, as far as can be shown? */
|
|
65
|
+
async check(url) {
|
|
66
|
+
const host = hostname(url);
|
|
67
|
+
if (!host)
|
|
68
|
+
return { state: "ok", site: null };
|
|
69
|
+
const site = siteKeyOf(host);
|
|
70
|
+
const entry = this.file.sites[site];
|
|
71
|
+
if (!entry)
|
|
72
|
+
return SIGN_IN_REQUIRED_SITES.includes(site) ? { state: "missing", site, reason: "never_signed_in" } : { state: "ok", site: null };
|
|
73
|
+
if (entry.state === "sign_in_needed")
|
|
74
|
+
return { state: "missing", site, reason: entry.basis === "bounced_to_sign_in" ? "bounced_to_sign_in" : "never_signed_in" };
|
|
75
|
+
if (!entry.verifiable || !entry.cookieNames?.length)
|
|
76
|
+
return { state: "ok", site };
|
|
77
|
+
const now = this.now();
|
|
78
|
+
const present = new Set((await this.cookies()).filter((c) => cookieSite(c) === site && live(c, now)).map((c) => c.name));
|
|
79
|
+
return entry.cookieNames.every((n) => present.has(n)) ? { state: "ok", site } : { state: "missing", site, reason: "expired" };
|
|
80
|
+
}
|
|
81
|
+
/** A sign-in pause is starting for `site`: remember which of its cookies exist now. */
|
|
82
|
+
async beginSignIn(site) {
|
|
83
|
+
const now = this.now();
|
|
84
|
+
const seen = new Map();
|
|
85
|
+
for (const c of await this.cookies())
|
|
86
|
+
if (cookieSite(c) === site && live(c, now))
|
|
87
|
+
seen.set(c.name, c.expires);
|
|
88
|
+
this.before.set(site, seen);
|
|
89
|
+
}
|
|
90
|
+
/** The owner resumed after signing in: record the cookies that appeared or were renewed. Names only. */
|
|
91
|
+
async completeSignIn(site) {
|
|
92
|
+
const before = this.before.get(site) ?? new Map();
|
|
93
|
+
this.before.delete(site);
|
|
94
|
+
const now = this.now();
|
|
95
|
+
const names = (await this.cookies())
|
|
96
|
+
.filter((c) => cookieSite(c) === site && live(c, now) && (!before.has(c.name) || before.get(c.name) !== c.expires))
|
|
97
|
+
.map((c) => c.name);
|
|
98
|
+
const cookieNames = [...new Set(names)].sort().slice(0, 50);
|
|
99
|
+
const at = new Date(now).toISOString();
|
|
100
|
+
const prior = this.file.sites[site];
|
|
101
|
+
this.file.sites[site] = {
|
|
102
|
+
site,
|
|
103
|
+
state: "signed_in",
|
|
104
|
+
basis: "owner_signed_in",
|
|
105
|
+
...(prior?.loginHost ? { loginHost: prior.loginHost } : {}),
|
|
106
|
+
cookieNames,
|
|
107
|
+
verifiable: cookieNames.length > 0,
|
|
108
|
+
signedInAt: at,
|
|
109
|
+
updatedAt: at,
|
|
110
|
+
};
|
|
111
|
+
this.save();
|
|
112
|
+
return { fingerprint: cookieNames.length, verifiable: cookieNames.length > 0 };
|
|
113
|
+
}
|
|
114
|
+
/** A press on `fromUrl` landed on a sign-in page: the next run must ask for a sign-in first. */
|
|
115
|
+
learnSignInWall(fromUrl, signInUrl) {
|
|
116
|
+
const from = hostname(fromUrl);
|
|
117
|
+
if (!from)
|
|
118
|
+
return null;
|
|
119
|
+
const site = siteKeyOf(from);
|
|
120
|
+
const at = new Date(this.now()).toISOString();
|
|
121
|
+
const loginHost = hostname(signInUrl) ?? undefined;
|
|
122
|
+
this.file.sites[site] = { site, state: "sign_in_needed", basis: "bounced_to_sign_in", ...(loginHost ? { loginHost } : {}), updatedAt: at };
|
|
123
|
+
this.save();
|
|
124
|
+
return site;
|
|
125
|
+
}
|
|
126
|
+
save() {
|
|
127
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
128
|
+
const tmp = `${this.path}.tmp`;
|
|
129
|
+
writeFileSync(tmp, `${JSON.stringify(this.file, null, 2)}\n`, { mode: 0o600 });
|
|
130
|
+
renameSync(tmp, this.path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function hostname(url) {
|
|
134
|
+
try {
|
|
135
|
+
const u = new URL(url);
|
|
136
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.hostname.toLowerCase() : null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { crashReport, describeError, runtimeCrashIdentity } from "./crash-reports.js";
|
|
2
|
+
/**
|
|
3
|
+
* A request that failed on the runtime's side (5xx), as a crash report (#1087): method, path and the
|
|
4
|
+
* correlation ids the URL and headers carry — the instance header, a `/tasks/:id`, a `/sessions/:id`.
|
|
5
|
+
* Never the body. Called from the server's one request boundary.
|
|
6
|
+
*/
|
|
7
|
+
export function recordRequestFailure(log, req, error) {
|
|
8
|
+
const path = (req.url || "").split("?")[0];
|
|
9
|
+
const instance = req.headers["x-pags-instance-id"];
|
|
10
|
+
log.record(crashReport({
|
|
11
|
+
...runtimeCrashIdentity(),
|
|
12
|
+
kind: "request_failure",
|
|
13
|
+
...describeError(error),
|
|
14
|
+
route: `${req.method ?? "?"} ${path}`,
|
|
15
|
+
instanceId: typeof instance === "string" ? instance : undefined,
|
|
16
|
+
taskId: /\/tasks\/([^/]+)/.exec(path)?.[1],
|
|
17
|
+
sessionId: /\/sessions\/([^/]+)/.exec(path)?.[1],
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { CrashLog, runtimeCrashIdentity } from "./crash-reports.js";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -81,6 +82,8 @@ export class LocalRunner {
|
|
|
81
82
|
/** The profile dir Chrome was actually launched into (holds DevToolsActivePort). */
|
|
82
83
|
launchedProfileDir = null;
|
|
83
84
|
store;
|
|
85
|
+
/** This runtime's crash reports (#1087) — delivered to the platform by the CLI that runs it. */
|
|
86
|
+
crashLog;
|
|
84
87
|
/** Local tmux coding sessions (the second runtime — AgentCoder port). */
|
|
85
88
|
coding;
|
|
86
89
|
/** Local CLI browser research (#944): a signed-in Codex / Claude Code CLI researching through the policy bridge. */
|
|
@@ -99,8 +102,9 @@ export class LocalRunner {
|
|
|
99
102
|
this.config = config;
|
|
100
103
|
mkdirSync(config.dataDir, { recursive: true });
|
|
101
104
|
this.store = new RunnerStore(config.dataDir);
|
|
105
|
+
this.crashLog = new CrashLog(join(config.dataDir, "crash-reports.json"), runtimeCrashIdentity());
|
|
102
106
|
this.coding = new CodingRuntime(join(config.dataDir, "repos"));
|
|
103
|
-
const { browserFor, applyBrowserFor } = createRunnerBrowserFactories({ headless: config.headless, defaultTools: () => this.getMcp(), sharedPage: () => this.activePage });
|
|
107
|
+
const { browserFor, applyBrowserFor } = createRunnerBrowserFactories({ headless: config.headless, dataDir: config.dataDir, defaultTools: () => this.getMcp(), defaultCookies: async () => (await (await this.getBrowserContext()).cookies()).map((c) => ({ name: c.name, domain: c.domain, expires: c.expires })), sharedPage: () => this.activePage });
|
|
104
108
|
this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
|
|
105
109
|
this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
|
|
106
110
|
const localApplyTakeover = createLocalApplyTakeoverAdapter({ get: (id) => this.takeovers.get(id), set: (id, page) => this.takeovers.set(id, { page, reason: "local_apply", humanDone: false }), frame: (id) => this.takeoverFrame(id), input: (id, input) => this.takeoverInput(id, input), end: (id) => this.endTakeover(id) });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { URL } from "node:url";
|
|
3
|
+
import { recordRequestFailure } from "./request-failure.js";
|
|
3
4
|
import { LocalRunner, RunnerInputError } from "./runner.js";
|
|
4
5
|
import { probeGitSshIdentity } from "./coding/repo.js";
|
|
5
6
|
import { checkEngine } from "./coding/engine-check.js";
|
|
@@ -30,6 +31,11 @@ export function createRunnerServer(runner) {
|
|
|
30
31
|
}
|
|
31
32
|
catch (error) {
|
|
32
33
|
const status = error instanceof RunnerInputError ? error.status : 500;
|
|
34
|
+
// A request that failed on the runner's side is a crash report (#1087), not just a 500 to
|
|
35
|
+
// the caller: method, path and correlation ids from the URL and headers — never the body.
|
|
36
|
+
// A caller's bad input (`RunnerInputError`) is answered, not recorded.
|
|
37
|
+
if (status >= 500)
|
|
38
|
+
recordRequestFailure(runner.crashLog, req, error);
|
|
33
39
|
json(res, status, {
|
|
34
40
|
error: error instanceof Error ? error.message : String(error),
|
|
35
41
|
});
|
|
@@ -39,8 +45,15 @@ export function createRunnerServer(runner) {
|
|
|
39
45
|
export async function startRunnerServer(config) {
|
|
40
46
|
const runner = new LocalRunner(config);
|
|
41
47
|
const server = createRunnerServer(runner);
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
// A bind failure (EADDRINUSE, EACCES) REJECTS (#1087). It used to be an `error` event with no
|
|
49
|
+
// listener — an uncaught exception that the crash handler swallows, leaving a runtime that never
|
|
50
|
+
// started and never exited. Now the entrypoint records a `startup_failure` and exits non-zero.
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
server.once("error", reject);
|
|
53
|
+
server.listen(config.port, config.host, () => {
|
|
54
|
+
server.off("error", reject);
|
|
55
|
+
resolve();
|
|
56
|
+
});
|
|
44
57
|
});
|
|
45
58
|
const address = server.address();
|
|
46
59
|
const actualPort = address.port;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CrashLog, crashReport, readJsonOrQuarantine, runtimeCrashIdentity, writeJsonAtomic } from "./crash-reports.js";
|
|
3
3
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
4
|
+
/** Events kept in the local store. Older ones are dropped — and counted in `droppedEvents`. */
|
|
5
|
+
export const MAX_EVENTS = 500;
|
|
4
6
|
function emptyStore() {
|
|
5
7
|
return {
|
|
6
8
|
sessions: [],
|
|
@@ -8,10 +10,26 @@ function emptyStore() {
|
|
|
8
10
|
events: [],
|
|
9
11
|
};
|
|
10
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The runner's local sessions / tasks / events file.
|
|
15
|
+
*
|
|
16
|
+
* Crash-safe since #1087: every write is atomic (temp file + fsync + rename, `crash-reports.ts`), so
|
|
17
|
+
* a crash mid-write leaves the previous file intact rather than a truncated one; and a file that
|
|
18
|
+
* does not parse is QUARANTINED (moved to `runner-store.json.corrupt-<time>`) and reported in the
|
|
19
|
+
* crash log for delivery to the platform — it used to read as an empty store, and the next write
|
|
20
|
+
* then overwrote the evidence. Limits are those of `writeJsonAtomic`: the write in flight at a hard
|
|
21
|
+
* kill or power loss is lost, never half-applied.
|
|
22
|
+
*/
|
|
11
23
|
export class RunnerStore {
|
|
12
24
|
filePath;
|
|
25
|
+
crashLog;
|
|
13
26
|
constructor(dataDir) {
|
|
14
27
|
this.filePath = join(dataDir, "runner-store.json");
|
|
28
|
+
this.crashLog = new CrashLog(join(dataDir, "crash-reports.json"), runtimeCrashIdentity());
|
|
29
|
+
}
|
|
30
|
+
/** How many events the buffer has dropped, ever. */
|
|
31
|
+
droppedEventCount() {
|
|
32
|
+
return this.read().droppedEvents ?? 0;
|
|
15
33
|
}
|
|
16
34
|
listSessions() {
|
|
17
35
|
return this.read().sessions;
|
|
@@ -84,7 +102,10 @@ export class RunnerStore {
|
|
|
84
102
|
createdAt: new Date().toISOString(),
|
|
85
103
|
};
|
|
86
104
|
data.events.unshift(row);
|
|
87
|
-
|
|
105
|
+
if (data.events.length > MAX_EVENTS) {
|
|
106
|
+
data.droppedEvents = (data.droppedEvents ?? 0) + (data.events.length - MAX_EVENTS);
|
|
107
|
+
data.events.length = MAX_EVENTS;
|
|
108
|
+
}
|
|
88
109
|
this.write(data);
|
|
89
110
|
return row;
|
|
90
111
|
}
|
|
@@ -92,24 +113,26 @@ export class RunnerStore {
|
|
|
92
113
|
return this.read().events.slice(0, limit);
|
|
93
114
|
}
|
|
94
115
|
read() {
|
|
95
|
-
|
|
116
|
+
const got = readJsonOrQuarantine(this.filePath);
|
|
117
|
+
if (got.status === "missing")
|
|
96
118
|
return emptyStore();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
119
|
+
if (got.status === "quarantined") {
|
|
120
|
+
// Evidence, not an empty store: the file is moved aside, the move is reported, and only
|
|
121
|
+
// then does the runner continue on a fresh store.
|
|
122
|
+
const message = `runner store was unreadable and was moved to ${got.quarantinedTo}: ${got.error}`;
|
|
123
|
+
process.stderr.write(`[store] ${message}\n`);
|
|
124
|
+
this.crashLog.record(crashReport({ ...runtimeCrashIdentity(), kind: "store_corrupt", message }));
|
|
106
125
|
return emptyStore();
|
|
107
126
|
}
|
|
127
|
+
const parsed = got.value;
|
|
128
|
+
return {
|
|
129
|
+
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
130
|
+
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
|
|
131
|
+
events: Array.isArray(parsed.events) ? parsed.events : [],
|
|
132
|
+
droppedEvents: Number(parsed.droppedEvents) || 0,
|
|
133
|
+
};
|
|
108
134
|
}
|
|
109
135
|
write(data) {
|
|
110
|
-
|
|
111
|
-
writeFileSync(this.filePath, `${JSON.stringify(data, null, 2)}\n`, {
|
|
112
|
-
mode: 0o600,
|
|
113
|
-
});
|
|
136
|
+
writeJsonAtomic(this.filePath, data);
|
|
114
137
|
}
|
|
115
138
|
}
|