@proagentstore/cli 0.4.72 → 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.
|
@@ -157,22 +157,35 @@ export class BrowserBridge {
|
|
|
157
157
|
const last = [...this.visited].pop();
|
|
158
158
|
return last ? { domain: last } : {};
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
|
|
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 } });
|
|
162
164
|
return text(reason, true);
|
|
163
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
|
+
}
|
|
164
175
|
/** May the run be on this host — asking the owner, and waiting, when it is a new one. */
|
|
165
176
|
async admit(host, url) {
|
|
166
177
|
if (this.host.isDenied(host))
|
|
167
178
|
return `${host} is on this run's deny list.`;
|
|
168
|
-
if (this.host.isPermitted(host))
|
|
179
|
+
if (this.host.isPermitted(host)) {
|
|
180
|
+
this.allowed(host, this.host.consentIdFor(host) ? "consent" : "allow_list");
|
|
169
181
|
return null;
|
|
182
|
+
}
|
|
170
183
|
if (this.host.allowListOnly())
|
|
171
184
|
return `${host} is not on this run's list of allowed sites.`;
|
|
172
185
|
this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
|
|
173
186
|
const outcome = await this.host.pause("consent_required", { domain: host });
|
|
174
187
|
if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
|
|
175
|
-
this.
|
|
188
|
+
this.allowed(host, "consent");
|
|
176
189
|
return null;
|
|
177
190
|
}
|
|
178
191
|
return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
|
|
@@ -185,7 +198,7 @@ export class BrowserBridge {
|
|
|
185
198
|
return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
|
|
186
199
|
const refusal = await this.admit(host, url);
|
|
187
200
|
if (refusal)
|
|
188
|
-
return this.refuse("browser_navigate", refusal);
|
|
201
|
+
return this.refuse("browser_navigate", refusal, host);
|
|
189
202
|
const res = await this.browser.callTool("browser_navigate", { url });
|
|
190
203
|
if (res.isError)
|
|
191
204
|
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
@@ -47,16 +47,54 @@ export const LOCAL_BROWSER_EVENT_TYPES = [
|
|
|
47
47
|
"run.resumed",
|
|
48
48
|
"note",
|
|
49
49
|
];
|
|
50
|
-
export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended"];
|
|
50
|
+
export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended", "review.decision"];
|
|
51
51
|
export const LOCAL_BROWSER_ENGINE_AUTHS = ["subscription", "machine-login", "api-key", "missing_login", "unknown"];
|
|
52
52
|
export const LOCAL_BROWSER_SOURCE_FAILURE_REASONS = ["login_required", "captcha", "paywall", "access_denied", "robots", "timeout", "error"];
|
|
53
53
|
export const LOCAL_BROWSER_CAPS = { findings: 200, sourceFailures: 200, text: 4000, fieldCount: 40 };
|
|
54
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
|
+
}
|
|
55
92
|
/**
|
|
56
|
-
* Drop sensitive keys and bound sizes. A trace is for a supervisor
|
|
57
|
-
* carry a cookie, a password, a form value or a key — whatever the
|
|
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.
|
|
58
96
|
*/
|
|
59
|
-
export function redactDetail(detail, depth = 0) {
|
|
97
|
+
export function redactDetail(detail, depth = 0, secrets = []) {
|
|
60
98
|
if (!detail || typeof detail !== "object" || Array.isArray(detail) || depth > 3)
|
|
61
99
|
return undefined;
|
|
62
100
|
const out = {};
|
|
@@ -64,13 +102,13 @@ export function redactDetail(detail, depth = 0) {
|
|
|
64
102
|
if (SENSITIVE_KEY.test(k))
|
|
65
103
|
continue;
|
|
66
104
|
if (typeof v === "string")
|
|
67
|
-
out[k] = v.slice(0, 1000);
|
|
105
|
+
out[k] = redactText(v.slice(0, 1000), secrets);
|
|
68
106
|
else if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
69
107
|
out[k] = v;
|
|
70
108
|
else if (Array.isArray(v))
|
|
71
|
-
out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? x.slice(0, 300) : typeof x === "number" || typeof x === "boolean" ? x : null));
|
|
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));
|
|
72
110
|
else if (typeof v === "object") {
|
|
73
|
-
const nested = redactDetail(v, depth + 1);
|
|
111
|
+
const nested = redactDetail(v, depth + 1, secrets);
|
|
74
112
|
if (nested)
|
|
75
113
|
out[k] = nested;
|
|
76
114
|
}
|
|
@@ -167,8 +205,10 @@ export function parseLocalBrowserResult(raw) {
|
|
|
167
205
|
const detail = str(r.detail, 1000);
|
|
168
206
|
sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
|
|
169
207
|
}
|
|
170
|
-
const out = { runId, outcome, findings, sourceFailures, summary: str(o.summary, LOCAL_BROWSER_CAPS.text) ?? "", traceId, engineAuth };
|
|
171
|
-
|
|
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);
|
|
172
212
|
if (outcome === "failed")
|
|
173
213
|
out.error = error ?? "The run failed without a reason.";
|
|
174
214
|
return { result: out };
|
|
@@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
import { RunnerInputError } from "../errors.js";
|
|
27
27
|
import { BrowserBridge, domainWithin } from "./bridge.js";
|
|
28
28
|
import { BRIDGE_ENV } from "./bridge-stdio.js";
|
|
29
|
-
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, } from "./contract.js";
|
|
29
|
+
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
|
|
30
30
|
import { buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
|
|
31
31
|
const MAX_EVENTS = 2000;
|
|
32
32
|
const OUTPUT_LINES = 400;
|
|
@@ -76,11 +76,28 @@ export function parseEnvelope(raw) {
|
|
|
76
76
|
authMode: o.authMode,
|
|
77
77
|
workspace: ws.kind === "path" ? { kind: "path", path: ws.path } : { kind: "scratch" },
|
|
78
78
|
browserProfile: o.browserProfile,
|
|
79
|
-
policy: {
|
|
79
|
+
policy: {
|
|
80
|
+
mode: "research_only",
|
|
81
|
+
allowDomains: strList(p.allowDomains),
|
|
82
|
+
denyDomains: strList(p.denyDomains),
|
|
83
|
+
consentedDomains: strList(p.consentedDomains),
|
|
84
|
+
profileConsented: p.profileConsented === true,
|
|
85
|
+
consentIds: consentIdMap(p.consentIds),
|
|
86
|
+
},
|
|
80
87
|
limits,
|
|
81
88
|
resultSchema: { id: typeof schema.id === "string" ? schema.id : "findings", version: typeof schema.version === "number" ? schema.version : 1 },
|
|
82
89
|
};
|
|
83
90
|
}
|
|
91
|
+
/** Decision ids keyed by lowercase domain (or `*`), from an untrusted object. */
|
|
92
|
+
function consentIdMap(raw) {
|
|
93
|
+
const out = {};
|
|
94
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
95
|
+
for (const [k, v] of Object.entries(raw).slice(0, 200))
|
|
96
|
+
if (typeof v === "string" && SAFE_ID.test(v))
|
|
97
|
+
out[k.toLowerCase()] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
84
101
|
/**
|
|
85
102
|
* A `~/…` workspace, resolved under the home directory — re-checked here against the real disk,
|
|
86
103
|
* because the API could only check the string. A symlink out of home is refused like `..` is.
|
|
@@ -142,6 +159,8 @@ export class LocalBrowserRuntime {
|
|
|
142
159
|
consented: new Set(envelope.policy.consentedDomains),
|
|
143
160
|
deny: new Set(envelope.policy.denyDomains),
|
|
144
161
|
profileConsented: envelope.policy.profileConsented,
|
|
162
|
+
consentIds: new Map(Object.entries(envelope.policy.consentIds ?? {})),
|
|
163
|
+
secrets: secretEnvValues(process.env),
|
|
145
164
|
activeSince: this.now(),
|
|
146
165
|
activeMs: 0,
|
|
147
166
|
waiters: [],
|
|
@@ -165,10 +184,13 @@ export class LocalBrowserRuntime {
|
|
|
165
184
|
if (outcome !== "resumed" || !run.profileConsented)
|
|
166
185
|
throw new Error("The owner did not allow research in their signed-in browser profile.");
|
|
167
186
|
}
|
|
187
|
+
if (e.browserProfile === "default")
|
|
188
|
+
this.emit(run, { type: "policy.decision", ...this.consentRef(run, "*"), detail: { scope: "signed_in_profile", decision: "consented" } });
|
|
168
189
|
run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
|
|
169
190
|
run.bridge = new BrowserBridge(run.browser.tools, {
|
|
170
191
|
emit: (ev) => this.emit(run, ev),
|
|
171
192
|
pause: (reason, detail) => this.pause(run, reason, detail),
|
|
193
|
+
consentIdFor: (host) => this.consentRef(run, host).consentId,
|
|
172
194
|
isDenied: (host) => [...run.deny].some((d) => domainWithin(host, d)),
|
|
173
195
|
isPermitted: (host) => [...e.policy.allowDomains, ...run.consented].some((d) => domainWithin(host, d)),
|
|
174
196
|
allowListOnly: () => e.policy.allowDomains.length > 0,
|
|
@@ -186,6 +208,8 @@ export class LocalBrowserRuntime {
|
|
|
186
208
|
});
|
|
187
209
|
if (spec.mcpConfig)
|
|
188
210
|
writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
|
|
211
|
+
// The env the engine actually gets — a key the owner chose to pass (api-key mode) included.
|
|
212
|
+
run.secrets = secretEnvValues({ ...process.env, ...spec.env });
|
|
189
213
|
run.engineAuth = observedEngineAuth(e.engine, spec.env);
|
|
190
214
|
this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
|
|
191
215
|
const child = this.spawn(spec.command, spec.args, { cwd: run.workDir, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -231,7 +255,7 @@ export class LocalBrowserRuntime {
|
|
|
231
255
|
if (run.events.length >= MAX_EVENTS)
|
|
232
256
|
return;
|
|
233
257
|
const { detail: raw, ...rest } = ev;
|
|
234
|
-
const detail = redactDetail(raw);
|
|
258
|
+
const detail = redactDetail(raw, 0, run.secrets);
|
|
235
259
|
run.events.push({ ...rest, ...(detail && Object.keys(detail).length ? { detail } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
236
260
|
}
|
|
237
261
|
pause(run, reason, detail) {
|
|
@@ -262,6 +286,8 @@ export class LocalBrowserRuntime {
|
|
|
262
286
|
const run = this.get(String(o.runId ?? ""));
|
|
263
287
|
if (run.state === "ended")
|
|
264
288
|
throw new RunnerInputError("The run has ended", 409);
|
|
289
|
+
for (const [d, id] of Object.entries(consentIdMap(o.consentIds)))
|
|
290
|
+
run.consentIds.set(d, id);
|
|
265
291
|
for (const d of strList(o.consentedDomains))
|
|
266
292
|
run.consented.add(d);
|
|
267
293
|
for (const d of strList(o.denyDomains))
|
|
@@ -313,6 +339,16 @@ export class LocalBrowserRuntime {
|
|
|
313
339
|
return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
|
|
314
340
|
throw new RunnerInputError("op must be list or call");
|
|
315
341
|
}
|
|
342
|
+
/** The decision that covers this host (or `*`), as an event field — empty when none does. */
|
|
343
|
+
consentRef(run, host) {
|
|
344
|
+
if (host === "*")
|
|
345
|
+
return run.consentIds.has("*") ? { consentId: run.consentIds.get("*") } : {};
|
|
346
|
+
let best = null;
|
|
347
|
+
for (const [d, id] of run.consentIds)
|
|
348
|
+
if (d !== "*" && domainWithin(host, d) && (!best || d.length > best[0].length))
|
|
349
|
+
best = [d, id];
|
|
350
|
+
return best ? { consentId: best[1] } : {};
|
|
351
|
+
}
|
|
316
352
|
get(runId) {
|
|
317
353
|
const run = this.runs.get(runId);
|
|
318
354
|
if (!run)
|
|
@@ -367,10 +403,11 @@ export class LocalBrowserRuntime {
|
|
|
367
403
|
outcome: r.outcome,
|
|
368
404
|
findings: run.bridge?.findings ?? [],
|
|
369
405
|
sourceFailures: run.bridge?.sourceFailures ?? [],
|
|
370
|
-
summary: (r.summary ?? "").slice(0, 4000),
|
|
406
|
+
summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
|
|
371
407
|
traceId: run.envelope.runId,
|
|
372
408
|
engineAuth: r.engineAuth ?? run.engineAuth,
|
|
373
|
-
|
|
409
|
+
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
410
|
+
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
374
411
|
};
|
|
375
412
|
if (run.child && run.child.exitCode === null)
|
|
376
413
|
this.kill(run);
|