@proagentstore/cli 0.4.88 → 0.4.89

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.
@@ -25,6 +25,7 @@
25
25
  * The trace carries classes, domains, decisions and artifact handles — never a typed value.
26
26
  */
27
27
  import { classifyApplyClick } from "./contract.js";
28
+ import { CONFIRMATION_INTERVAL_SECONDS, CONFIRMATION_LOOKS, CONFIRMATION_TEXT_RE, confirmationMarker, observe, } from "./confirmation.js";
28
29
  import { ELEMENT_PROBE_FN } from "../commit-guard.js";
29
30
  import { normalize } from "../local-artifact/engine.js";
30
31
  import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
@@ -58,8 +59,15 @@ const CONSENT_NAME = /\b(agree|consent|terms|privacy|acknowledge|certify|accept|
58
59
  const CREDENTIAL_NAME = /password|passcode|one[- ]?time|\botp\b|verification code|security code|2fa|two[- ]factor/i;
59
60
  /** Keys that move between fields without entering or activating anything. */
60
61
  const SAFE_KEYS = /^(Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Escape|Home|End|PageUp|PageDown)$/;
61
- /** Runs inside the page, through the browser's own evaluate tool — never offered to the CLI. */
62
+ /**
63
+ * Runs inside the page, through the browser's own evaluate tool — never offered to the CLI.
64
+ *
65
+ * The success vocabulary is INTERPOLATED from `confirmation.ts` rather than written out here
66
+ * (#994): a second copy of that regex is how the "sent" family — the exact word SEEK uses — was
67
+ * missing from the detector while being present in nobody's tests, through three live runs.
68
+ */
62
69
  const INSPECT_PAGE = `() => {
70
+ const CONFIRMATION_TEXT_SOURCE = ${JSON.stringify(CONFIRMATION_TEXT_RE.source)};
63
71
  const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
64
72
  const has = (s) => !!document.querySelector(s);
65
73
  return {
@@ -70,7 +78,7 @@ const INSPECT_PAGE = `() => {
70
78
  login: has('input[type=password]'),
71
79
  duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
72
80
  antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
73
- confirmed: /thank you for (your )?appl|application (has been |was )?(received|submitted)|we('ve| have) received your application|successfully (applied|submitted)/.test(text),
81
+ confirmed: new RegExp(CONFIRMATION_TEXT_SOURCE).test(text),
74
82
  unavailable: /(?:this |the )?(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/.test(text)
75
83
  ? (/expired/.test(text) ? "expired" : "unavailable")
76
84
  : null,
@@ -120,6 +128,8 @@ export class ApplyBridge {
120
128
  /** Present only after the CLI explicitly reports a notice that the runner independently saw. */
121
129
  unavailable = null;
122
130
  submitAttempted = false;
131
+ /** What was seen after the submit click (#994) — read by the runtime to build the diagnostic. */
132
+ confirmation = null;
123
133
  submitted = null;
124
134
  /** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
125
135
  blocked = null;
@@ -609,17 +619,44 @@ export class ApplyBridge {
609
619
  this.submitAttempted = true;
610
620
  this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
611
621
  const res = await this.browser.callTool("browser_click", forward);
612
- await this.browser.callTool("browser_wait_for", { time: 2 }).catch(() => undefined);
613
- const after = res.isError ? null : await this.inspect();
614
- if (after?.confirmed) {
615
- this.submitted = { url: after.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
616
- this.host.emit({ type: "submit.confirmed", url: after.url, domain: hostOf(after.url) ?? domain, detail: { gateId: this.host.gateId } });
622
+ // Look REPEATEDLY, not once (#994). The old path waited two seconds and read the page a
623
+ // single time, so a receipt that arrives on a navigation or a client-side re-render a moment
624
+ // later was simply not there yet when the only look happened. `submitAttempted` is already
625
+ // true above, so waiting longer cannot make a pressed button look unpressed.
626
+ const { after, looks } = res.isError ? { after: null, looks: 0 } : await this.awaitConfirmation(before);
627
+ const seen = observe(before, after, looks);
628
+ this.confirmation = seen;
629
+ const evidence = { looks: seen.looks, urlChanged: seen.urlChanged, titleChanged: seen.titleChanged };
630
+ if (seen.marker) {
631
+ const at = after;
632
+ this.submitted = { url: at.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
633
+ this.host.emit({ type: "submit.confirmed", url: at.url, domain: hostOf(at.url) ?? domain, detail: { gateId: this.host.gateId, marker: seen.marker, ...evidence } });
617
634
  return text("Submitted, and the site confirmed it. Stop now.");
618
635
  }
619
636
  this.blocked = { reason: "submit_unconfirmed", questions: ["The application was submitted but the site did not confirm it. Check the employer's site or your email before anything is retried."] };
620
- this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
637
+ this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId, ...evidence } });
621
638
  return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
622
639
  }
640
+ /**
641
+ * Read the page until it confirms, or until the looks run out (#994).
642
+ *
643
+ * It returns the LAST page it read, not the first: when nothing confirms, the owner's
644
+ * diagnostic should describe where the browser actually ended up. Bounded by
645
+ * `CONFIRMATION_LOOKS` — an application run holds the owner's only browser, so "look again"
646
+ * must never become "sit here".
647
+ */
648
+ async awaitConfirmation(before) {
649
+ let after = null;
650
+ for (let i = 0; i < CONFIRMATION_LOOKS; i++) {
651
+ await this.browser.callTool("browser_wait_for", { time: CONFIRMATION_INTERVAL_SECONDS }).catch(() => undefined);
652
+ const page = await this.inspect();
653
+ if (page)
654
+ after = page;
655
+ if (confirmationMarker(before, page))
656
+ return { after: page, looks: i + 1 };
657
+ }
658
+ return { after, looks: CONFIRMATION_LOOKS };
659
+ }
623
660
  /** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
624
661
  requireSupervisor(tool, actionClass) {
625
662
  if (this.supervisorApproved)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Did the employer's site confirm the submission? (#994)
3
+ *
4
+ * ── The live failure this module exists for
5
+ *
6
+ * Three distinct approved auto-submit runs on `au.seek.com` reached `submit.attempted` and all
7
+ * three ended terminal `blocked / submit_unconfirmed` with `submittedAt=null`: applications
8
+ * `435d31c8…` (run `a4bf5c0f…`), `71765edd…` (run `76416747…`) and `b6244557…` (run `2f2e88a5…`).
9
+ * The honest safety state was right — nothing was retried, and the owner was told to check the
10
+ * employer's site — but a 3/3 rate says the DETECTOR was wrong, not the sites.
11
+ *
12
+ * Three facts about the old detector, each read off the code rather than guessed at:
13
+ *
14
+ * 1. **The vocabulary had no "sent".** It matched `thank you for (your) appl`,
15
+ * `application (has been|was) (received|submitted)`, `we('ve| have) received your application`
16
+ * and `successfully (applied|submitted)` — and nothing else. SEEK's own post-apply wording is
17
+ * the "sent" family ("Application sent", "Your application has been sent", "You've applied"),
18
+ * so the one word the page actually uses was the one word the regex could not see.
19
+ * 2. **It looked once, 2 seconds after the click** (`browser_wait_for {time: 2}` then one
20
+ * `inspect()`), so a success state that arrives on a navigation or a client-side re-render a
21
+ * moment later was simply not there yet when the only look happened.
22
+ * 3. **Text was the only evidence considered.** The URL the click navigates to, the title, and
23
+ * the site's own "you have already applied" notice were all collected by `INSPECT_PAGE` and
24
+ * none of them counted.
25
+ *
26
+ * What remains UNVERIFIED, and is stated here rather than implied: no live SEEK page was captured
27
+ * while writing this, so which of the three was decisive for those three runs is not established.
28
+ * The tests below use realistic fixtures of the wording SEEK and the big ATSs publish, which the
29
+ * issue explicitly allows; a live capture would still be worth taking.
30
+ *
31
+ * ── What counts as confirmation
32
+ *
33
+ * A named marker, never a feeling. Every marker is an id this platform defines, and only the ID
34
+ * crosses the contract — never the text that matched it. That is the same rule `signalsOf` follows
35
+ * in `runtime.ts` for engine output, and for the same reason: an application page holds the
36
+ * owner's typed answers, and no redaction reliably separates those from the rest of the prose.
37
+ *
38
+ * The URL and duplicate markers are deliberately comparative — they need the page as it was BEFORE
39
+ * the click, which the submit path already has. A `/success` URL that was already there is not
40
+ * evidence of this click; "you have already applied" that was already there is somebody's earlier
41
+ * application, not this one.
42
+ */
43
+ /**
44
+ * The success vocabulary, as ONE source used in two places: this module (tested in Node) and the
45
+ * in-page script (built from `.source`, executed in the browser). Two copies of a regex is how the
46
+ * "sent" family went missing from one of them for three live runs.
47
+ *
48
+ * Lower-cased text is matched, so the patterns carry no case class.
49
+ */
50
+ export const CONFIRMATION_TEXT_RE = /thank you for (your )?appl|application (has been |was )?(received|submitted|sent)|we('ve| have) received your application|successfully (applied|submitted|sent)|your application (is|has been) (on its way|complete)|you(r application)?('ve| have)? (now )?applied|applied (on|to this job)|application (sent|complete|submitted) *[.!]?$/;
51
+ /**
52
+ * A URL that is itself the receipt. Matched against the path and query of the page the click
53
+ * landed on, and only when the click CHANGED it.
54
+ *
55
+ * Kept narrow on purpose: `/apply` alone is the form, not the confirmation, and treating it as one
56
+ * would turn "the click did nothing" into "submitted".
57
+ */
58
+ export const CONFIRMATION_URL_RE = /(^|\/)(apply|application|applications|job)?[^/]*\/(success|succeeded|complete|completed|confirmation|confirmed|submitted|sent|thank-?you|receipt)(\/|$|\?)|[?&](applied|submitted|success)=(1|true|yes)/;
59
+ /** Ordered by how strong the evidence is: the first match is the one recorded. */
60
+ export function confirmationMarker(before, after) {
61
+ if (!after)
62
+ return null;
63
+ if (after.confirmed)
64
+ return "page_text";
65
+ const url = urlPathAndQuery(after.url).toLowerCase();
66
+ if (CONFIRMATION_URL_RE.test(url) && !!before && urlPathAndQuery(before.url).toLowerCase() !== url)
67
+ return "url_receipt";
68
+ // The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
69
+ // applied" that was on the page beforehand is an earlier application of the owner's, and
70
+ // reading it as this run's success would record a submission that never happened.
71
+ if (after.duplicate && before && !before.duplicate)
72
+ return "already_applied_notice";
73
+ return null;
74
+ }
75
+ /** Path + query only: a host or a fragment is not evidence, and the host is recorded separately. */
76
+ function urlPathAndQuery(raw) {
77
+ try {
78
+ const u = new URL(raw);
79
+ return `${u.pathname}${u.search}`;
80
+ }
81
+ catch {
82
+ return raw;
83
+ }
84
+ }
85
+ /**
86
+ * How long to keep looking, and how often.
87
+ *
88
+ * The old path waited 2s once. These are bounded for the reason every wait in this runner is: an
89
+ * application run holds the owner's only browser, so "look again" must not become "sit here". Six
90
+ * looks over ~9s covers a navigation and a client-side re-render, and stops long before the
91
+ * engine's own minute budget notices.
92
+ */
93
+ export const CONFIRMATION_LOOKS = 6;
94
+ export const CONFIRMATION_INTERVAL_SECONDS = 1.5;
95
+ export function observe(before, after, looks) {
96
+ return {
97
+ marker: confirmationMarker(before, after),
98
+ looks,
99
+ urlChanged: !!after && !!before && after.url !== before.url,
100
+ titleChanged: !!after && !!before && after.title !== before.title,
101
+ unreadable: !after,
102
+ };
103
+ }
104
+ /**
105
+ * The closed-vocabulary signals that go on the run's diagnostic when a submit was pressed and not
106
+ * confirmed, so the owner's record says what was seen rather than only what was not.
107
+ *
108
+ * Each is an id, so nothing here can carry page text; together they distinguish the three cases a
109
+ * person would act on differently: the page never changed (the click may not have landed), the page
110
+ * moved but said nothing this detector knows (a wording to add), and the page could not be read.
111
+ */
112
+ export function confirmationSignals(o) {
113
+ const out = [];
114
+ if (o.unreadable)
115
+ out.push("confirmation_page_unreadable");
116
+ else if (!o.marker)
117
+ out.push("confirmation_no_marker");
118
+ if (o.urlChanged)
119
+ out.push("confirmation_url_changed");
120
+ else if (!o.unreadable)
121
+ out.push("confirmation_url_unchanged");
122
+ if (o.titleChanged)
123
+ out.push("confirmation_title_changed");
124
+ return out;
125
+ }
@@ -82,31 +82,38 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
82
82
  * dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
83
83
  * `runnerContractProblem`).
84
84
  *
85
- * ── Why it moved to 0.4.85 (#989)
85
+ * ── Why it moved to 0.4.89 (#994)
86
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.
87
+ * #994 makes a post-click confirmation observable and recognises SEEK's "application sent"
88
+ * receipt wording. It also carries only closed-vocabulary evidence when a pressed submit remains
89
+ * unconfirmed. Those changes live in `packages/browser-runner`, which ships INSIDE the published
90
+ * CLI. A machine below this floor cannot provide that evidence, so it must not silently look
91
+ * equivalent to a current runner.
96
92
  *
97
93
  * 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.
94
+ * So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
95
+ * with a sentence that says so, rather than reporting an unhelpful `submit_unconfirmed` state.
101
96
  *
102
97
  * Bump this when the runner's half of the contract changes again, together with the CLI version
103
98
  * that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
104
99
  * been published. `cliAtLeast` treats an unreported version as capable, which is the convention
105
100
  * every MIN_CLI gate here follows.
106
101
  */
107
- export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.85";
108
- export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
109
- export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
102
+ export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.89";
103
+ export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
104
+ export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
105
+ export const LOCAL_APPLY_SIGNALS = [
106
+ "approval_policy_blocked",
107
+ "bridge_tools_missing",
108
+ "auth_prompt",
109
+ "no_output",
110
+ "engine_refused_task",
111
+ "confirmation_no_marker",
112
+ "confirmation_page_unreadable",
113
+ "confirmation_url_changed",
114
+ "confirmation_url_unchanged",
115
+ "confirmation_title_changed",
116
+ ];
110
117
  export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
111
118
  export const LOCAL_APPLY_EVENT_TYPES = [
112
119
  "engine.auth_checked",
@@ -140,7 +147,11 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
140
147
  // submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
141
148
  // refused the control that opens the form" from "it refused the real submit" never reached the
142
149
  // 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"]);
150
+ // `marker`, `looks`, `urlChanged` and `titleChanged` are #994's post-click evidence: a marker ID
151
+ // from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
152
+ // Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
153
+ // text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
154
+ 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", "marker", "looks", "urlChanged", "titleChanged"]);
144
155
  /** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
145
156
  export function parseLocalApplyEvent(raw) {
146
157
  if (!raw || typeof raw !== "object")
@@ -167,6 +178,15 @@ export function parseLocalApplyEvent(raw) {
167
178
  for (const [k, v] of Object.entries(o.detail)) {
168
179
  if (!DETAIL_KEYS.has(k))
169
180
  continue;
181
+ // #994: `marker` is a runner-defined confirmation ID, never the success sentence the
182
+ // page displayed. Keep the closed vocabulary closed even if a compromised/outdated
183
+ // runner tries to use this otherwise-string field as a prose channel.
184
+ if (k === "marker") {
185
+ const marker = oneOf(LOCAL_APPLY_CONFIRMATION_MARKERS, v);
186
+ if (marker)
187
+ detail[k] = marker;
188
+ continue;
189
+ }
170
190
  if (typeof v === "string")
171
191
  detail[k] = v.slice(0, 300);
172
192
  else if (typeof v === "number" || typeof v === "boolean")
@@ -33,6 +33,7 @@ import { redactText, secretEnvValues } from "../local-browser/contract.js";
33
33
  import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
34
34
  import { resolveWorkspacePath } from "../local-browser/runtime.js";
35
35
  import { ApplyBridge } from "./bridge.js";
36
+ import { confirmationSignals } from "./confirmation.js";
36
37
  import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
37
38
  import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
38
39
  const MAX_EVENTS = 1000;
@@ -372,8 +373,20 @@ export class LocalApplyRuntime {
372
373
  return { outcome: "submitted", summary, submitted: b.submitted };
373
374
  if (b?.unavailable)
374
375
  return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
375
- if (b?.submitAttempted)
376
- return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
376
+ // #994: a pressed submit nobody confirmed now SAYS what was seen. The outcome is unchanged —
377
+ // terminal, no retry, the owner told to check the employer's site — because that safety is
378
+ // correct; what was missing was any way to tell "the click never moved the page" from "the
379
+ // page moved and said something this platform does not recognise yet".
380
+ if (b?.submitAttempted) {
381
+ const d = this.diagnosticOf(run, code, "submit_unconfirmed");
382
+ return {
383
+ outcome: "blocked",
384
+ summary,
385
+ blockReason: "submit_unconfirmed",
386
+ questions: b.blocked?.questions ?? [],
387
+ diagnostic: { ...d, signals: [...d.signals, ...(b.confirmation ? confirmationSignals(b.confirmation) : [])] },
388
+ };
389
+ }
377
390
  if (b?.reviewReady)
378
391
  return { outcome: "awaiting_review", summary };
379
392
  if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.88",
3
+ "version": "0.4.89",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",