@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 {
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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"
|
|
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);
|