@proagentstore/cli 0.4.85 → 0.4.87
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.
|
@@ -50,6 +50,7 @@ export const LOCAL_BROWSER_EVENT_TYPES = [
|
|
|
50
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
|
+
export const LOCAL_BROWSER_ERROR_CODES = ["browser_unavailable", "engine_not_installed", "engine_not_signed_in", "bridge_unused", "consent_declined", "time_limit", "cancelled", "engine_failed"];
|
|
53
54
|
export const LOCAL_BROWSER_CAPS = { findings: 200, sourceFailures: 200, text: 4000, fieldCount: 40 };
|
|
54
55
|
const SENSITIVE_KEY = /cookie|password|passwd|secret|token|authorization|api[_-]?key|otp|form[_-]?values?|credential/i;
|
|
55
56
|
/** An env var NAME whose value is a credential. */
|
|
@@ -147,6 +148,36 @@ export function parseLocalBrowserEvent(raw) {
|
|
|
147
148
|
event.detail = detail;
|
|
148
149
|
return event;
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* A finding, with every free-text part redacted (#947).
|
|
153
|
+
*
|
|
154
|
+
* The gap this closes: the result envelope's `summary` and `error` went through {@link redactText}
|
|
155
|
+
* and its FINDINGS did not. `title`, `evidence` and `detail` were length-capped only, and `fields`
|
|
156
|
+
* was filtered by KEY ({@link SENSITIVE_KEY}) — so a credential-shaped value under an innocuous key
|
|
157
|
+
* (`{"note": "sk-ant-api03-…"}`) survived, and a secret environment value quoted into `evidence`
|
|
158
|
+
* survived verbatim. Both are written by the same CLI, on the same machine, in the same envelope as
|
|
159
|
+
* the summary that WAS cleaned, and findings are the part that persists furthest: the run's result
|
|
160
|
+
* row, the console, MCP, and then the owner's own collection record. #947's observability rule —
|
|
161
|
+
* "do not retain … secret environment values" — was therefore breached on the longest-lived path.
|
|
162
|
+
*
|
|
163
|
+
* One definition, used by the runner (which knows the machine's secret env values and passes them)
|
|
164
|
+
* and again by the API when it parses what arrived (which does not, and catches shapes). Defence in
|
|
165
|
+
* depth on purpose: the runner is where secrets are knowable, and the cloud is where a runner that
|
|
166
|
+
* skipped the step is still caught.
|
|
167
|
+
*/
|
|
168
|
+
export function redactFinding(f, secrets = []) {
|
|
169
|
+
const fields = {};
|
|
170
|
+
for (const [k, v] of Object.entries(f.fields ?? {})) {
|
|
171
|
+
if (SENSITIVE_KEY.test(k))
|
|
172
|
+
continue;
|
|
173
|
+
fields[k] = typeof v === "string" ? redactText(v, secrets) : v;
|
|
174
|
+
}
|
|
175
|
+
return { title: redactText(f.title, secrets), url: f.url, evidence: redactText(f.evidence, secrets), fields };
|
|
176
|
+
}
|
|
177
|
+
/** The same for a source failure's free text; its `reason` is a closed vocabulary and its url is a url. */
|
|
178
|
+
export function redactSourceFailure(f, secrets = []) {
|
|
179
|
+
return f.detail === undefined ? f : { ...f, detail: redactText(f.detail, secrets) };
|
|
180
|
+
}
|
|
150
181
|
/**
|
|
151
182
|
* A validated result envelope, or the reason it is not one.
|
|
152
183
|
*
|
|
@@ -193,7 +224,9 @@ export function parseLocalBrowserResult(raw) {
|
|
|
193
224
|
else if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
194
225
|
fields[k.slice(0, 100)] = v;
|
|
195
226
|
}
|
|
196
|
-
|
|
227
|
+
// #947: capped and key-filtered is not redacted. A finding is the longest-lived thing a run
|
|
228
|
+
// produces, and its free text was the one part of this envelope nothing cleaned.
|
|
229
|
+
findings.push(redactFinding({ title, url, evidence, fields }));
|
|
197
230
|
}
|
|
198
231
|
const sourceFailures = [];
|
|
199
232
|
for (const [i, f] of o.sourceFailures.entries()) {
|
|
@@ -203,13 +236,19 @@ export function parseLocalBrowserResult(raw) {
|
|
|
203
236
|
if (!url || !reason)
|
|
204
237
|
return { error: `sourceFailures[${i}] needs url and a known reason` };
|
|
205
238
|
const detail = str(r.detail, 1000);
|
|
206
|
-
sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
|
|
239
|
+
sourceFailures.push(redactSourceFailure(detail ? { url, reason, detail } : { url, reason }));
|
|
207
240
|
}
|
|
208
241
|
const out = { runId, outcome, findings, sourceFailures, summary: redactText(str(o.summary, LOCAL_BROWSER_CAPS.text) ?? ""), traceId, engineAuth };
|
|
209
242
|
// Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
|
|
210
243
|
const rawError = str(o.error, 1000);
|
|
211
244
|
const error = rawError === null ? null : redactText(rawError);
|
|
212
|
-
if (outcome === "failed")
|
|
245
|
+
if (outcome === "failed") {
|
|
213
246
|
out.error = error ?? "The run failed without a reason.";
|
|
247
|
+
// #944: the runner's own name for the cause, when it gave one. An unrecognised code is
|
|
248
|
+
// dropped rather than passed through, so the stored vocabulary stays closed.
|
|
249
|
+
const code = oneOf(LOCAL_BROWSER_ERROR_CODES, o.errorCode);
|
|
250
|
+
if (code)
|
|
251
|
+
out.errorCode = code;
|
|
252
|
+
}
|
|
214
253
|
return { result: out };
|
|
215
254
|
}
|
|
@@ -26,8 +26,28 @@ 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, redactText, secretEnvValues, } from "./contract.js";
|
|
29
|
+
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactFinding, redactSourceFailure, redactText, secretEnvValues, } from "./contract.js";
|
|
30
30
|
import { bridgeUnused, buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
|
|
31
|
+
/**
|
|
32
|
+
* Why the run could not be launched, in words the owner can act on (#944).
|
|
33
|
+
*
|
|
34
|
+
* Two shapes reach here: the owner declining the signed-in profile, which is a DECISION and not a
|
|
35
|
+
* fault, and the browser failing to start — the "missing browser" state the acceptance criteria
|
|
36
|
+
* ask for. Everything a browser can refuse at startup is fixed at the machine (install Chrome, free
|
|
37
|
+
* the profile another window is holding, allow the automation port), so the sentence says that; the
|
|
38
|
+
* raw message rides along, because it is the half that names which of those it was.
|
|
39
|
+
*/
|
|
40
|
+
function launchFailure(err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
if (/did not allow research in their signed-in browser profile/i.test(message)) {
|
|
43
|
+
return { outcome: "failed", error: message, errorCode: "consent_declined" };
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
outcome: "failed",
|
|
47
|
+
error: `The browser for this run could not be started on this machine: ${message.slice(0, 400)}. Check that Chrome or Chromium is installed, that no other window is holding the profile this run uses, and that \`pags up\` may launch it — then start the run again.`,
|
|
48
|
+
errorCode: "browser_unavailable",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
31
51
|
const MAX_EVENTS = 2000;
|
|
32
52
|
const OUTPUT_LINES = 400;
|
|
33
53
|
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,100}$/;
|
|
@@ -172,7 +192,10 @@ export class LocalBrowserRuntime {
|
|
|
172
192
|
this.runs.set(envelope.runId, run);
|
|
173
193
|
// Not awaited: a signed-in-profile consent pause can hold the launch for minutes, and the
|
|
174
194
|
// relay command that started the run must answer now. Everything after this is in `status`.
|
|
175
|
-
|
|
195
|
+
// #944: a launch failure is the one the acceptance criteria call "missing browser", and it was
|
|
196
|
+
// the one cause with no name: it ended the run with whatever the browser threw, which the
|
|
197
|
+
// cloud then filed as `engine_failed` — the CLI blamed for a browser that never started.
|
|
198
|
+
void this.launch(run, selfUrl).catch((err) => this.end(run, launchFailure(err)));
|
|
176
199
|
return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
|
|
177
200
|
}
|
|
178
201
|
async launch(run, selfUrl) {
|
|
@@ -230,7 +253,12 @@ export class LocalBrowserRuntime {
|
|
|
230
253
|
child.stderr?.on("data", keep);
|
|
231
254
|
child.on("error", (err) => {
|
|
232
255
|
const missing = err.code === "ENOENT";
|
|
233
|
-
this.end(run, {
|
|
256
|
+
this.end(run, {
|
|
257
|
+
outcome: "failed",
|
|
258
|
+
...(missing
|
|
259
|
+
? { error: `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).`, errorCode: "engine_not_installed" }
|
|
260
|
+
: { error: err.message }),
|
|
261
|
+
});
|
|
234
262
|
});
|
|
235
263
|
child.on("close", (code) => {
|
|
236
264
|
if (buf.trim())
|
|
@@ -377,12 +405,12 @@ export class LocalBrowserRuntime {
|
|
|
377
405
|
const summary = bridge?.summary ?? finalText(e.engine, run.output);
|
|
378
406
|
const found = (bridge?.findings.length ?? 0) > 0;
|
|
379
407
|
if (run.cancelled)
|
|
380
|
-
return { outcome: "failed", error: "Cancelled by the owner" };
|
|
408
|
+
return { outcome: "failed", error: "Cancelled by the owner", errorCode: "cancelled" };
|
|
381
409
|
if (missingLogin(e.engine, run.output.join("\n")) && !found)
|
|
382
|
-
return { outcome: "failed", error: signInHelp(e.engine), engineAuth: "missing_login" };
|
|
410
|
+
return { outcome: "failed", error: signInHelp(e.engine), errorCode: "engine_not_signed_in", engineAuth: "missing_login" };
|
|
383
411
|
const limit = `Stopped at the ${e.limits.maxMinutes}-minute limit.`;
|
|
384
412
|
if (run.timedOut)
|
|
385
|
-
return { outcome: found ? "completed" : "failed", summary: summary || limit, error: limit };
|
|
413
|
+
return { outcome: found ? "completed" : "failed", summary: summary || limit, error: limit, errorCode: "time_limit" };
|
|
386
414
|
if (code !== 0 && !bridge?.summary) {
|
|
387
415
|
const tail = run.output.slice(-5).join("\n").slice(-800);
|
|
388
416
|
return { outcome: "failed", summary, error: `The ${e.engine} CLI exited with code ${code}${tail ? `: ${tail}` : ""}` };
|
|
@@ -391,7 +419,7 @@ export class LocalBrowserRuntime {
|
|
|
391
419
|
// says. Live run aac758dc (#952) ended `completed` with "the browser bridge required approval":
|
|
392
420
|
// no page opened, and the run looked like a success with nothing found.
|
|
393
421
|
if (!bridge?.calls)
|
|
394
|
-
return { outcome: "failed", summary, error: bridgeUnused(e.engine, summary) };
|
|
422
|
+
return { outcome: "failed", summary, error: bridgeUnused(e.engine, summary), errorCode: "bridge_unused" };
|
|
395
423
|
return { outcome: "completed", summary };
|
|
396
424
|
}
|
|
397
425
|
end(run, r) {
|
|
@@ -406,13 +434,22 @@ export class LocalBrowserRuntime {
|
|
|
406
434
|
run.result = {
|
|
407
435
|
runId: run.envelope.runId,
|
|
408
436
|
outcome: r.outcome,
|
|
409
|
-
|
|
410
|
-
|
|
437
|
+
// #947: the machine's own secret env values are knowable HERE and nowhere later, so the
|
|
438
|
+
// findings are cleaned with them — the same treatment `summary` and `error` below already
|
|
439
|
+
// had, and the part of this envelope that previously kept whatever the CLI wrote.
|
|
440
|
+
findings: (run.bridge?.findings ?? []).map((f) => redactFinding(f, run.secrets)),
|
|
441
|
+
sourceFailures: (run.bridge?.sourceFailures ?? []).map((f) => redactSourceFailure(f, run.secrets)),
|
|
411
442
|
summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
|
|
412
443
|
traceId: run.envelope.runId,
|
|
413
444
|
engineAuth: r.engineAuth ?? run.engineAuth,
|
|
414
445
|
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
415
|
-
...(r.outcome === "failed"
|
|
446
|
+
...(r.outcome === "failed"
|
|
447
|
+
? {
|
|
448
|
+
error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets),
|
|
449
|
+
// #944: WHICH cause, named here rather than guessed by the cloud from `engineAuth`.
|
|
450
|
+
errorCode: r.errorCode ?? "engine_failed",
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
416
453
|
};
|
|
417
454
|
if (run.child && run.child.exitCode === null)
|
|
418
455
|
this.kill(run);
|