@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
- findings.push({ title, url, evidence, fields });
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
- void this.launch(run, selfUrl).catch((err) => this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) }));
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, { outcome: "failed", error: missing ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
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
- findings: run.bridge?.findings ?? [],
410
- sourceFailures: run.bridge?.sourceFailures ?? [],
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" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.85",
3
+ "version": "0.4.87",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",