@proagentstore/cli 0.4.84 → 0.4.86

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.
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * The trace carries classes, domains, decisions and artifact handles — never a typed value.
26
26
  */
27
- import { ELEMENT_PROBE_FN, FALLBACK_COMMIT_RE } from "../commit-guard.js";
27
+ import { classifyApplyClick } from "./contract.js";
28
+ import { ELEMENT_PROBE_FN } from "../commit-guard.js";
28
29
  import { normalize } from "../local-artifact/engine.js";
29
30
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
30
31
  const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
@@ -329,9 +330,24 @@ export class ApplyBridge {
329
330
  const facts = await this.probe(t.ref, t.name);
330
331
  if (!facts)
331
332
  return this.refuse("browser_click", "read", "That element could not be read from the page, so the click cannot be shown to be safe. Take a fresh browser_snapshot.");
332
- const commits = (facts.submits && facts.method === "post") || [facts.name, t.name, String(forward.element)].some((n) => n && FALLBACK_COMMIT_RE.test(n));
333
- if (commits)
334
- return this.submit(forward);
333
+ // WHICH control is this? (#985) The rule is in the shared contract, which states the three
334
+ // families and why the order of the tests is the safety property. It replaced a test against
335
+ // `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` on purpose — under
336
+ // which the press that OPENS a SEEK application was the final submit, and four live
337
+ // fill-and-review runs ended `awaiting_review` with `filled: 0` seconds after the cloud had
338
+ // let them through their initial checkpoint.
339
+ const klass = classifyApplyClick({
340
+ role: t.role,
341
+ names: [facts.name, t.name, String(forward.element)],
342
+ submits: facts.submits,
343
+ method: facts.method,
344
+ filled: this.filled,
345
+ uploaded: this.uploaded.size,
346
+ });
347
+ if (klass.klass === "submit")
348
+ return this.submit(forward, klass.reason);
349
+ if (klass.klass === "entry")
350
+ return this.entry(forward, t, klass.reason);
335
351
  if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
336
352
  if (this.pages >= this.host.limits.maxPages)
337
353
  return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
@@ -342,7 +358,7 @@ export class ApplyBridge {
342
358
  }
343
359
  // A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
344
360
  const fileInput = facts.tag === "input" && facts.type === "file";
345
- if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
361
+ if (fileInput || klass.klass === "step" || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
346
362
  const checkpoint = this.requireSupervisor("browser_click", "fill");
347
363
  if (checkpoint)
348
364
  return checkpoint;
@@ -508,20 +524,76 @@ export class ApplyBridge {
508
524
  return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
509
525
  }
510
526
  review(summary) {
511
- this.reviewReady = true;
512
527
  this.summary = summary; // bounded where the result is built (runtime.ts `end`)
528
+ // #989: "ready for review" with nothing entered is not a review state.
529
+ //
530
+ // The live run ended `awaiting_review` with `filled: 0, uploaded: 0` seconds after the cloud
531
+ // had let it through its initial checkpoint, because a refused submit-class click called
532
+ // this. An owner reading that is told a form is populated and waiting for them — and the one
533
+ // action it invites (#981's approve-and-continue) would send an empty application. There is
534
+ // no form to review, so the honest terminal outcome is the recorded blocker and its remedy
535
+ // (`runtime.ts` `outcomeOf` reads `blocked` once `reviewReady` is false).
536
+ if (!this.filled && !this.uploaded.size && this.blocked) {
537
+ this.host.emit({ type: "policy.decision", detail: { tool: "ready_for_review", class: "review", decision: "refused", reason: this.blocked.reason, count: 0 } });
538
+ return text(`Nothing was entered on this application, so there is nothing to review: the run is recorded as ${this.blocked.reason.replace(/_/g, " ")}. Stop now.`, true);
539
+ }
540
+ this.reviewReady = true;
513
541
  this.host.emit({ type: "review.ready", detail: { class: "review", count: this.filled } });
514
542
  return text("Recorded. The application waits for the owner's review; nothing was submitted. Stop now.");
515
543
  }
544
+ /**
545
+ * The control that OPENS the application (#985) — pressed, never submitted.
546
+ *
547
+ * It needs the supervisor's live `continue` exactly as a fill does: it changes the page, and the
548
+ * cloud is the party that decides whether this page may be worked on. `afterMove` then counts the
549
+ * page, traces it and clears the approval, so the form that opens gets its own checkpoint — which
550
+ * is what keeps a multi-page ATS supervised page by page rather than once at the start.
551
+ */
552
+ async entry(forward, t, reason) {
553
+ const checkpoint = this.requireSupervisor("browser_click", "fill");
554
+ if (checkpoint)
555
+ return checkpoint;
556
+ if (this.pages >= this.host.limits.maxPages)
557
+ return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
558
+ this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "entry", decision: "allowed", reason, role: t.role } });
559
+ const res = await this.browser.callTool("browser_click", forward);
560
+ if (res.isError)
561
+ return { content: [{ type: "text", text: textOf(res) }], isError: true };
562
+ return this.afterMove("browser_click", textOf(res));
563
+ }
516
564
  /** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
517
- async submit(forward) {
565
+ async submit(forward, why = "terminal_label") {
518
566
  const checkpoint = this.requireSupervisor("browser_click", "submit");
519
567
  if (checkpoint)
520
568
  return checkpoint;
521
569
  if (this.host.mode !== "auto_submit" || !this.host.gateId) {
522
- this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
523
- this.review("Stopped at the final submit: this run fills and waits for review.");
524
- return text("That control submits the application, and this run is fill-and-review: it was NOT pressed. The application waits for the owner's review. Stop now.");
570
+ // `reason` carries WHICH rule classified this as the submit (#985). "fill_and_review"
571
+ // alone left an owner with a run that stopped at `filled: 0` and no way to tell whether it
572
+ // had reached the end of the form or refused the button that opens it.
573
+ // The RULE, not the control's label: the label is the page's own prose and belongs to the
574
+ // machine (the contract keeps page text off the cloud trace). The rule is what an owner
575
+ // needs — "this was the one-click control", not "this was a submit".
576
+ this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review", rule: why } });
577
+ const oneClick = why === "one_click_apply";
578
+ // #989: a refusal with NOTHING entered is not a review state, so it is recorded as the
579
+ // blocker it is. `review` then declines to mark the run ready (see there), and the owner
580
+ // gets a terminal outcome they can act on — approve this application, or open it
581
+ // themselves — instead of an empty form presented as waiting for their approval. With
582
+ // work on the page the old reading holds: there IS a filled form to look at.
583
+ if (!this.filled && !this.uploaded.size) {
584
+ this.blocked = {
585
+ reason: "incomplete",
586
+ questions: [
587
+ oneClick
588
+ ? "This listing's apply control can send the application in one click, so a fill-and-review run may not press it, and nothing was entered. Approve this application to let it be sent, or apply on the site yourself."
589
+ : "The run reached the control that submits this application without entering anything — the form may already be complete from your profile, or it may not have opened. Approve this application to let it be sent, or open it yourself.",
590
+ ],
591
+ };
592
+ }
593
+ this.review(oneClick ? "Stopped at a one-click apply control: it can send the application outright, and this run fills and waits for review." : "Stopped at the final submit: this run fills and waits for review.");
594
+ return text(oneClick
595
+ ? 'That control ("' + String(forward.element ?? "") + '") can send the application in one click, and this run is fill-and-review: it was NOT pressed. Approve this application if you want it sent. Stop now.'
596
+ : "That control submits the application, and this run is fill-and-review: it was NOT pressed. The application waits for the owner's review. Stop now.");
525
597
  }
526
598
  const before = await this.inspect();
527
599
  if (!before)
@@ -58,6 +58,53 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
58
58
  "job_unavailable",
59
59
  "bridge_unused",
60
60
  ];
61
+ /**
62
+ * Why a run produced nothing on the page (#975) — a CLOSED vocabulary, never free text.
63
+ *
64
+ * The production case: the CLI authenticated, exited 0 after 51 seconds, and emitted no browser,
65
+ * checkpoint, fill or review event. The outcome said `incomplete`, which is also what a half-filled
66
+ * form says, so the operator had no way to tell "it did nothing" from "it stopped partway" — and no
67
+ * way at all to learn WHY without reading a terminal on the machine.
68
+ *
69
+ * Deliberately NOT an output tail, redacted or otherwise. `redactText` removes credential SHAPES; it
70
+ * cannot remove the owner's own résumé prose, their typed answers or a quoted source document, all
71
+ * of which a CLI legitimately prints while it works. So nothing free-form is added here: every field
72
+ * is a count, a code, or an id from {@link LOCAL_APPLY_SIGNALS} that the RUNNER chose by matching
73
+ * its own output. The engine's closing sentence continues to cross as `summary`, where it already
74
+ * did — redacted and capped — so this adds diagnosis without adding a new channel for content.
75
+ */
76
+ /**
77
+ * The first published CLI whose runner executes THIS contract (#977, raised at #989).
78
+ *
79
+ * An older runner is not broken; it simply predates the vocabulary. Left to run, it reports the
80
+ * same failure in the PREVIOUS shape, which is indistinguishable from a run under the new contract
81
+ * — exactly the live confusion #977 was filed from. So a machine below this is refused BEFORE
82
+ * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
+ * `runnerContractProblem`).
84
+ *
85
+ * ── Why it moved to 0.4.85 (#989)
86
+ *
87
+ * #985 fixed the runner's click classification — the press that OPENS a SEEK application stopped
88
+ * being read as the final submit ({@link classifyApplyClick}) — and that fix lives in
89
+ * `packages/browser-runner`, which ships INSIDE the published CLI. The commit did not bump
90
+ * `packages/cli/package.json`, and `publish-npm.yml` publishes only a version that is new, so
91
+ * nothing was published: the live machine kept running 0.4.84, whose bundled bridge still tests
92
+ * `FALLBACK_COMMIT_RE`. The next live run therefore reproduced the defect the fix had already
93
+ * removed from `main` — `browser_click → class: submit → refused: fill_and_review`, no `rule`
94
+ * field, three seconds after the cloud's own `continue` — and the only durable evidence that the
95
+ * code was old was the absence of a field nobody looks for.
96
+ *
97
+ * A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
98
+ * So this floor moves with it: a machine that cannot tell the entry control from the final submit
99
+ * is refused with a sentence that says so, instead of ending `awaiting_review` having typed
100
+ * nothing.
101
+ *
102
+ * Bump this when the runner's half of the contract changes again, together with the CLI version
103
+ * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
104
+ * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
105
+ * every MIN_CLI gate here follows.
106
+ */
107
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.85";
61
108
  export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
62
109
  export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
63
110
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
@@ -89,7 +136,11 @@ export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20
89
136
  const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
90
137
  const oneOf = (list, v) => (list.includes(v) ? v : null);
91
138
  const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
92
- const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
139
+ // `rule` is on this list from #989: the runner has emitted WHICH rule classified a click as the
140
+ // submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
141
+ // refused the control that opens the form" from "it refused the real submit" never reached the
142
+ // cloud trace, which is where the live investigation needed it.
143
+ 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"]);
93
144
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
94
145
  export function parseLocalApplyEvent(raw) {
95
146
  if (!raw || typeof raw !== "object")
@@ -208,3 +259,104 @@ export function parseLocalApplyResult(raw) {
208
259
  out.error = str(o.error, 1000) ?? "The run failed without a reason.";
209
260
  return { result: out };
210
261
  }
262
+ // ── Which control is the FINAL submit, and which one opens the form (#985) ───────────────────
263
+ /**
264
+ * ── The live failure
265
+ *
266
+ * Four consecutive fill-and-review runs on one SEEK posting ended `awaiting_review` with
267
+ * `filled: 0`. The last of them (`286bbb8a…`, 2026-10-08) got everything right up to the last
268
+ * step: the runner reported its `initial` checkpoint, the cloud's deterministic policy decided
269
+ * `continue` (#982), the directive was delivered and acknowledged and the CLI resumed — and then,
270
+ * three seconds later, the press that OPENS the application was classified as the final submit,
271
+ * refused under `fill_and_review`, and the run ended at review having typed nothing.
272
+ *
273
+ * The cause was a borrowed vocabulary. The bridge tested the control's label against the runner's
274
+ * `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` deliberately, because for
275
+ * an agent that may never act, refusing a filter button called "Apply" is the cheap error. For a
276
+ * run whose entire capability is to fill the form it is the expensive one. The cloud's own
277
+ * `commit-guard.ts` had already written the distinction down twice ("Apply/Next/Continue … are the
278
+ * ENTRY button on most multi-page ATS"; the APPLY family is terminal "only after something has
279
+ * been typed") — the apply bridge just had no list of its own to reach for.
280
+ *
281
+ * ── The three families, and why the order of the tests is the safety property
282
+ *
283
+ * 1. ONE-CLICK ({@link ONE_CLICK_SUBMIT_RE}) — "Quick apply", "Easy Apply", "Apply with your
284
+ * profile". On LinkedIn, and on a profile-apply SEEK listing, that control can send the
285
+ * application outright. Tested FIRST and always terminal, because the two families overlap in
286
+ * English and the overlap is only dangerous in one direction: reading "Quick apply" as an entry
287
+ * control is how an unapproved application reaches an employer.
288
+ * 2. TERMINAL ({@link TERMINAL_APPLY_SUBMIT_RE}) — "Submit", "Send application", "Finish", in
289
+ * twenty languages: the control that sends what has been filled.
290
+ * 3. ENTRY ({@link APPLY_ENTRY_RE}) — "Apply", "Apply for this job", "Next", "Continue", and their
291
+ * equivalents: the control that opens or advances the form. Only ever an entry while nothing has
292
+ * been entered yet and the control is not a POST submit; once a field is filled, the APPLY
293
+ * family becomes terminal again, which is the rule the cloud guard already states.
294
+ *
295
+ * Everything outside the three families is decided by the DOM, as before: a native POST submit is
296
+ * a submit. The one case that changed is a POST submit labelled as a STEP with nothing filled yet
297
+ * ("Save and continue" on page 1) — that stays walkable, because a multi-page ATS cannot be filled
298
+ * otherwise, and the final page's control says SUBMIT.
299
+ */
300
+ const applyWordish = (token) => `(?<![\\p{L}\\p{N}])${token}(?![\\p{L}\\p{N}])`;
301
+ /** A control that can SEND the application on its own. Terminal, whatever else it looks like. */
302
+ export const ONE_CLICK_SUBMIT_RE = new RegExp([
303
+ "quick apply|easy apply|instant apply|one[- ]?click|1[- ]?click|apply with (your )?(profile|r[ée]sum[ée]|cv|linkedin|indeed|seek)|apply using (your )?profile",
304
+ ...["schnellbewerbung", "postulation rapide", "candidatura rapida", "snelle sollicitatie"].map(applyWordish),
305
+ ...["快速申请", "簡単応募", "간편지원"],
306
+ ].join("|"), "iu");
307
+ /** The control that SENDS a filled application. Mirrors the cloud guard's terminal English set. */
308
+ export const TERMINAL_APPLY_SUBMIT_RE = new RegExp([
309
+ "\\b(submit|finish|done|complete|confirm)\\b|send application|submit application|send my application",
310
+ ...[
311
+ "envoyer", "soumettre", "valider", "finaliser", "terminer", "confirmer",
312
+ "absenden", "abschicken", "senden", "einreichen", "best[äa]tigen", "abschlie[sß]en", "fertigstellen",
313
+ "enviar", "confirmar", "finalizar", "completar", "submeter", "concluir",
314
+ "invia", "inviare", "conferma", "confermare", "completa", "termina", "finalizza",
315
+ "verstuur", "versturen", "verzenden", "indienen", "bevestig", "bevestigen", "voltooien", "afronden",
316
+ "skicka", "sende", "bekr[äae]fta", "bekreft", "bekr[æa]ft", "slutf[öo]r", "fullf[øo]r", "afslut",
317
+ "wy[śs]lij", "z[łl]ó[żz]", "potwierd[źz]", "zako[ńn]cz", "odeslat", "potvrdit", "dokon[čc]it",
318
+ "g[öo]nder", "onayla", "tamamla", "kirim", "kirimkan", "ajukan", "konfirmasi",
319
+ "g[ửu]i", "n[ộo]p", "отправить", "подтвердить", "завершить", "подать",
320
+ ].map(applyWordish),
321
+ ...["提交", "送出", "确认", "確認", "完成", "送信", "提出", "完了", "제출", "보내기", "확인", "완료", "إرسال", "تقديم", "تأكيد"],
322
+ ].join("|"), "iu");
323
+ /** The control that OPENS or ADVANCES the form — never the one that sends it. */
324
+ export const APPLY_ENTRY_RE = new RegExp([
325
+ "\\b(apply|apply now|apply for this job|apply for this role|view (and )?apply|start( your)? application|begin( your)? application|next|continue|save and continue|start)\\b",
326
+ ...["postuler", "candidater", "bewerben", "candidati", "solliciteer", "aplikuj", "aplicar", "postular", "candidatar", "ans[øo]g", "başvur", "lamar", "откликнуться"].map(applyWordish),
327
+ ...["申请", "应聘", "応募する", "応募", "지원하기", "التقديم"],
328
+ ].join("|"), "iu");
329
+ /**
330
+ * Classify one click. PURE, so the rule that decides whether a real application is sent is
331
+ * assertable without a browser — and identical in both copies of this file.
332
+ *
333
+ * Order is the specification: one-click first (it can send), then terminal, then the DOM's own
334
+ * verdict once anything has been entered, then entry/step.
335
+ */
336
+ export function classifyApplyClick(input) {
337
+ const names = input.names.map((n) => (n ?? "").trim()).filter((n) => n.length > 0);
338
+ const hit = (re) => names.find((n) => re.test(n));
339
+ const entered = input.filled > 0 || input.uploaded > 0;
340
+ const oneClick = hit(ONE_CLICK_SUBMIT_RE);
341
+ if (oneClick)
342
+ return { klass: "submit", reason: "one_click_apply" };
343
+ const terminal = hit(TERMINAL_APPLY_SUBMIT_RE);
344
+ if (terminal)
345
+ return { klass: "submit", reason: "terminal_label" };
346
+ // A native POST submit, once something HAS been entered: there is an application to send, so
347
+ // the benefit of the doubt goes to the employer rather than to the run.
348
+ if (input.submits && input.method === "post" && entered)
349
+ return { klass: "submit", reason: "post_submit_after_fill" };
350
+ const entry = hit(APPLY_ENTRY_RE);
351
+ // Nothing entered yet: an APPLY/NEXT control opens the form. This is the press that ended four
352
+ // live runs at `filled: 0` — and it cannot be the final submit, because nothing has been typed.
353
+ if (entry && !entered)
354
+ return { klass: "entry", reason: input.submits ? "entry_post_form_nothing_filled" : "entry_label_nothing_filled" };
355
+ if (entry)
356
+ return { klass: "step", reason: "step_after_fill" };
357
+ // A POST submit with no recognised label and nothing entered is a page-advance on a multi-page
358
+ // form; with something entered it was caught above.
359
+ if (input.submits && input.method === "post")
360
+ return { klass: "step", reason: "post_submit_nothing_filled" };
361
+ return { klass: "other", reason: "unrecognised" };
362
+ }
@@ -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. */
@@ -209,7 +210,13 @@ export function parseLocalBrowserResult(raw) {
209
210
  // Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
210
211
  const rawError = str(o.error, 1000);
211
212
  const error = rawError === null ? null : redactText(rawError);
212
- if (outcome === "failed")
213
+ if (outcome === "failed") {
213
214
  out.error = error ?? "The run failed without a reason.";
215
+ // #944: the runner's own name for the cause, when it gave one. An unrecognised code is
216
+ // dropped rather than passed through, so the stored vocabulary stays closed.
217
+ const code = oneOf(LOCAL_BROWSER_ERROR_CODES, o.errorCode);
218
+ if (code)
219
+ out.errorCode = code;
220
+ }
214
221
  return { result: out };
215
222
  }
@@ -28,6 +28,26 @@ import { BrowserBridge, domainWithin } from "./bridge.js";
28
28
  import { BRIDGE_ENV } from "./bridge-stdio.js";
29
29
  import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, 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) {
@@ -412,7 +440,13 @@ export class LocalBrowserRuntime {
412
440
  traceId: run.envelope.runId,
413
441
  engineAuth: r.engineAuth ?? run.engineAuth,
414
442
  // 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) } : {}),
443
+ ...(r.outcome === "failed"
444
+ ? {
445
+ error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets),
446
+ // #944: WHICH cause, named here rather than guessed by the cloud from `engineAuth`.
447
+ errorCode: r.errorCode ?? "engine_failed",
448
+ }
449
+ : {}),
416
450
  };
417
451
  if (run.child && run.child.exitCode === null)
418
452
  this.kill(run);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.84",
3
+ "version": "0.4.86",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",