@proagentstore/cli 0.4.52 → 0.4.54

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The commit guard AT THE ACT BOUNDARY — the runner half of #627 / #629.
3
+ *
4
+ * ── Why it has to be here
5
+ *
6
+ * `dryRun` and `readOnly` were enforced entirely in the cloud, on `action.name`: a string the MODEL
7
+ * wrote ABOUT the element. This process is the one that actually clicks, and it clicks by `ref`
8
+ * ({@link https://github.com/microsoft/playwright-mcp} `browser_click({ element, target })` — only
9
+ * `target` locates; `element` is a human-readable description). So the guard tested a story about
10
+ * the act while the act went somewhere else entirely, and the runner — the only party holding the
11
+ * DOM, and therefore the only one that can KNOW whether a control submits — had never been told
12
+ * that a run was a rehearsal at all: `grep -rn dryRun packages/browser-runner/src` returned eight
13
+ * hits and every one of them was the orphaned-browser reaper's unrelated `--dry-run` flag.
14
+ *
15
+ * A nameless click had already submitted a real application during a run the owner asked to be a
16
+ * test. The fix at the time made an EMPTY name a refusal, which left a WRONG name — a paraphrase,
17
+ * an `aria-label` the model rewrote, a page in French — behaving exactly as before.
18
+ *
19
+ * ── What is a fact here and what is still a guess
20
+ *
21
+ * FACT (`read_only`): whether the targeted control submits a form, and whether that form is a POST.
22
+ * Language-independent, label-independent, and not something the brain can talk its way past. A
23
+ * GET form is a search — the read-only prompt explicitly allows finding things — so only POST is
24
+ * refused. Enter and Space are checked against the FOCUSED element the same way.
25
+ *
26
+ * GUESS (both rehearsal modes): which of several POST submits on a multi-page ATS is the FINAL one.
27
+ * Nothing in the DOM says so — "Save and Continue" on page 3 and "Submit application" on page 6 are
28
+ * the same kind of control — and a rehearsal must be able to walk the whole form. So a rehearsal
29
+ * still decides on a LABEL. What changed is whose label: the element's own accessible name, read
30
+ * back out of the live DOM, instead of the model's claim about it, and a vocabulary that is not
31
+ * English-only. That residual is recorded on the issue rather than papered over.
32
+ */
33
+ /**
34
+ * The FLOOR vocabulary, used only when the cloud sent none. It is deliberately the read-only
35
+ * (widest) set: a runner that has been told "read_only" but not told the words must not fail open.
36
+ * The authoritative list is `workers/api/src/lib/commit-guard.ts`, and `commit-guard.test.ts` there
37
+ * asserts this file still parses as a regex, so a copy that rots is a red build rather than a
38
+ * silent downgrade.
39
+ */
40
+ export const FALLBACK_COMMIT_RE = /\b(confirm|accept|submit|send|post|publish|delete|remove|pay|purchase|buy|apply|approve|agree|save)\b|(?<![\p{L}\p{N}])(envoyer|soumettre|valider|absenden|abschicken|senden|einreichen|enviar|invia|inviare|verstuur|versturen|verzenden|indienen|skicka|wy[śs]lij|g[öo]nder|kirim|отправить)(?![\p{L}\p{N}])|提交|送出|确认|確認|送信|提出|제출|보내기|إرسال|تقديم/iu;
41
+ /** Compile the policy the cloud sent, falling back to the floor above. A malformed pattern must
42
+ * NOT disarm the guard — it falls back rather than throwing, because the caller is about to act. */
43
+ export function commitLabelRe(spec) {
44
+ if (!spec?.labels)
45
+ return FALLBACK_COMMIT_RE;
46
+ try {
47
+ return new RegExp(spec.labels, spec.flags || "iu");
48
+ }
49
+ catch {
50
+ return FALLBACK_COMMIT_RE;
51
+ }
52
+ }
53
+ /** Keys that submit a focused form. `NumpadEnter` submits identically and was matched by nothing. */
54
+ export const SUBMIT_KEY_RE = /^(enter|return|numpadenter)$/i;
55
+ /** Space activates the focused control, including a submit button. */
56
+ export const ACTIVATE_KEY_RE = /^(space|spacebar|\s)$/i;
57
+ /** The probe evaluated ON the targeted element. Kept as source text because it crosses into the
58
+ * page through the standard `browser_evaluate` tool, which takes a function expression. */
59
+ export const ELEMENT_PROBE_FN = `el => {
60
+ var btn = (el.closest && el.closest('button,input,a,[role=button]')) || el;
61
+ var tag = (btn.tagName || '').toLowerCase();
62
+ var type = ((btn.getAttribute && btn.getAttribute('type')) || '').toLowerCase();
63
+ var form = btn.form || (btn.closest && btn.closest('form')) || null;
64
+ var submits = !!form && ((tag === 'button' && type !== 'button' && type !== 'reset') || (tag === 'input' && (type === 'submit' || type === 'image')));
65
+ var name = (btn.getAttribute && btn.getAttribute('aria-label')) || btn.value || btn.innerText || btn.textContent || '';
66
+ return { submits: submits, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: tag, type: type, name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
67
+ }`;
68
+ /** The probe for a keypress: there is no ref, so it reads whatever has focus. */
69
+ export const FOCUS_PROBE_FN = `(() => {
70
+ var el = document.activeElement;
71
+ if (!el) return { inForm: false, method: '', tag: '', type: '', name: '' };
72
+ var form = el.form || (el.closest && el.closest('form')) || null;
73
+ var name = (el.getAttribute && el.getAttribute('aria-label')) || el.value || el.innerText || el.textContent || '';
74
+ return { inForm: !!form, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: (el.tagName || '').toLowerCase(), type: ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
75
+ })()`;
76
+ /**
77
+ * May this click reach the page? Returns the refusal to hand back to the brain, or null.
78
+ *
79
+ * `facts` is null when the element could not be probed (an evaluate that failed, a ref the page no
80
+ * longer has). That is not treated as permission: in read-only it is refused outright, and in a
81
+ * rehearsal it falls back to the claimed name, which is the behaviour that shipped.
82
+ */
83
+ export function refuseClick(spec, facts, claimedName, re) {
84
+ const claimed = (claimedName ?? "").trim();
85
+ const real = (facts?.name ?? "").trim();
86
+ if (spec.mode === "read_only") {
87
+ if (!facts) {
88
+ return "BLOCKED by the runner — this agent is READ-ONLY and that element could not be read from the page, so the click cannot be shown to be safe. Re-read the snapshot and target an element from it.";
89
+ }
90
+ if (facts.submits && facts.method === "post") {
91
+ return `BLOCKED by the runner — this agent is READ-ONLY and "${real || claimed || facts.tag}" submits a form (POST) on this page. That is a change, whatever the control is called. Report what you can already see with finish.`;
92
+ }
93
+ }
94
+ const hit = [real, claimed].find((n) => n && re.test(n));
95
+ if (!hit)
96
+ return null;
97
+ return spec.mode === "read_only"
98
+ ? `BLOCKED by the runner — this agent is READ-ONLY and can never perform "${hit}". Report what you can already see with finish.`
99
+ : `BLOCKED by the runner — this is a REHEARSAL and "${hit}" commits. The page never received the click. Call finish now instead of retrying.`;
100
+ }
101
+ /**
102
+ * May this keypress reach the page?
103
+ *
104
+ * Read-only only. A rehearsal's Enter is decided by the cloud loop, which holds the one piece of
105
+ * state that makes the call — an Enter immediately after an arrow key is an autocomplete ACCEPT,
106
+ * not a submit — and this process cannot tell a JS listbox that will swallow the key from a form
107
+ * that will take it.
108
+ */
109
+ export function refuseKey(spec, key, focus) {
110
+ if (spec.mode !== "read_only")
111
+ return null;
112
+ const k = (key ?? "").trim();
113
+ const submitKey = SUBMIT_KEY_RE.test(k);
114
+ const activateKey = ACTIVATE_KEY_RE.test(k);
115
+ if (!submitKey && !activateKey)
116
+ return null;
117
+ if (!focus) {
118
+ return `BLOCKED by the runner — this agent is READ-ONLY and the focused element could not be read, so pressing ${k || "Enter"} cannot be shown to be safe.`;
119
+ }
120
+ if (submitKey && focus.inForm && focus.method === "post") {
121
+ return `BLOCKED by the runner — this agent is READ-ONLY and pressing ${k} submits the form this field belongs to (POST). A search or filter that only reads (GET) is fine; this one writes. Report what you can already see with finish.`;
122
+ }
123
+ if (activateKey && (focus.tag === "button" || focus.type === "submit") && focus.method === "post") {
124
+ return `BLOCKED by the runner — this agent is READ-ONLY and Space activates "${focus.name || "the focused button"}", which submits a form (POST).`;
125
+ }
126
+ return null;
127
+ }
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
6
6
  import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
7
7
  import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
8
8
  import { McpRuntime } from "./mcp-runtime.js";
9
+ import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
9
10
  import { HumanHandoffError, RunnerInputError } from "./errors.js";
10
11
  import { RunnerStore } from "./store.js";
11
12
  import { CodingRuntime } from "./coding/runtime.js";
@@ -837,6 +838,46 @@ export class LocalRunner {
837
838
  return false;
838
839
  return null;
839
840
  }
841
+ /** Evaluate a function ON an element (by snapshot ref) and parse its JSON result. Never throws
842
+ * — a null return means "the page could not be asked", which callers must not read as a yes. */
843
+ async evalJson(mcp, ref, label, fn) {
844
+ const res = await mcp.callTool("browser_evaluate", { element: label, target: ref, function: fn }).catch(() => null);
845
+ if (!res || res.isError)
846
+ return null;
847
+ const txt = mcp.textOf(res);
848
+ const i = txt.indexOf("### Result");
849
+ if (i < 0)
850
+ return null;
851
+ const after = txt.slice(i + "### Result".length).trim();
852
+ const end = after.indexOf("\n###");
853
+ const block = (end >= 0 ? after.slice(0, end) : after).trim();
854
+ try {
855
+ return JSON.parse(block);
856
+ }
857
+ catch {
858
+ return null;
859
+ }
860
+ }
861
+ /**
862
+ * The commit guard, enforced HERE because this is the process that clicks (#627, #629).
863
+ *
864
+ * The cloud's pre-filter reads a label the model wrote; this one reads the element. For a
865
+ * read-only agent that is a DOM fact — does this control submit a POST form — which no label,
866
+ * in any language, can talk its way past. Returns the refusal to hand back to the brain.
867
+ */
868
+ async commitRefusal(mcp, page, action, guard) {
869
+ const re = commitLabelRe(guard);
870
+ if (action.action === "click") {
871
+ const ref = (action.ref || "").trim();
872
+ const facts = ref ? await this.evalJson(mcp, ref, action.name || action.role || "control", ELEMENT_PROBE_FN) : null;
873
+ return refuseClick(guard, facts, action.name, re);
874
+ }
875
+ if (action.action === "key") {
876
+ const focus = (await page.evaluate(FOCUS_PROBE_FN).catch(() => null));
877
+ return refuseKey(guard, action.key, focus);
878
+ }
879
+ return null;
880
+ }
840
881
  /** The snapshot ref the brain must target the element by (standard-tool `target`). */
841
882
  refOf(action) {
842
883
  const ref = (action.ref || "").trim();
@@ -943,7 +984,7 @@ export class LocalRunner {
943
984
  * element by its snapshot ref. A tool-level failure is thrown so the workflow
944
985
  * surfaces it to the brain as an `error` (which drives its self-correction).
945
986
  */
946
- async browserAct(action, resumePath) {
987
+ async browserAct(action, resumePath, guard) {
947
988
  const page = await this.getActivePage();
948
989
  // Arm résumé auto-attach so a file chooser never blocks the flow (see method).
949
990
  // resumePath may be a signed URL (remote runner) or a local path — resolve to
@@ -970,9 +1011,19 @@ export class LocalRunner {
970
1011
  title: await active.title().catch(() => ""),
971
1012
  challenge: await detectHumanChallenge(active),
972
1013
  screenshot: await this.shot(active),
1014
+ commitGuard: { supported: true, mode: guard?.mode },
973
1015
  };
974
1016
  }
975
1017
  const mcp = await this.getMcp();
1018
+ // BEFORE the tool call, never after: this is the last point at which the action is still
1019
+ // only a proposal. `commitGuard.supported` on every reply is how the cloud learns that this
1020
+ // enforcement exists here at all — measured from the runner's own answer, not guessed from
1021
+ // a version number, because the published CLI upgrades on the field's schedule.
1022
+ if (guard) {
1023
+ const refusal = await this.commitRefusal(mcp, page, action, guard);
1024
+ if (refusal)
1025
+ throw new RunnerInputError(refusal);
1026
+ }
976
1027
  const res = await this.callBrowserTool(mcp, action);
977
1028
  const text = mcp.textOf(res).trim();
978
1029
  // A native page dialog (alert/confirm/beforeunload) puts the standard server
@@ -994,6 +1045,7 @@ export class LocalRunner {
994
1045
  challenge: await detectHumanChallenge(settled),
995
1046
  feedback: dialogMsg ? `a native dialog was accepted: "${dialogMsg}"` : "a native dialog was accepted",
996
1047
  screenshot: await this.shot(settled),
1048
+ commitGuard: { supported: true, mode: guard?.mode },
997
1049
  };
998
1050
  }
999
1051
  if (res.isError)
@@ -1027,6 +1079,7 @@ export class LocalRunner {
1027
1079
  challenge: await detectHumanChallenge(active),
1028
1080
  feedback: feedback || undefined,
1029
1081
  screenshot: await this.shot(active),
1082
+ commitGuard: { supported: true, mode: guard?.mode },
1030
1083
  };
1031
1084
  }
1032
1085
  // ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
@@ -90,8 +90,12 @@ async function route(runner, req, res) {
90
90
  return json(res, 200, await runner.browserSnapshot(b.taskId));
91
91
  }
92
92
  if (req.method === "POST" && path === "/browser/act") {
93
+ // `guard` is the commit policy for THIS run (#627, #629) — a rehearsal or a read-only
94
+ // agent. It travels with every action rather than being registered once, because the
95
+ // runner serves many instances at once and a per-connection mode would be a second piece
96
+ // of state to get wrong.
93
97
  const body = await readJson(req);
94
- return json(res, 200, await runner.browserAct(body, body.resumePath));
98
+ return json(res, 200, await runner.browserAct(body, body.resumePath, body.guard));
95
99
  }
96
100
  if (req.method === "POST" && path === "/browser/event") {
97
101
  const b = await readJson(req);
@@ -1,9 +1,10 @@
1
1
  import { createServer, } from "node:http";
2
2
  export async function startTestJobServer(port = 0) {
3
3
  const submissions = [];
4
+ const searches = [];
4
5
  const server = createServer(async (req, res) => {
5
6
  try {
6
- await route(req, res, submissions);
7
+ await route(req, res, submissions, searches);
7
8
  }
8
9
  catch (error) {
9
10
  html(res, 500, `<h1>Server Error</h1><pre>${escapeHtml(String(error))}</pre>`);
@@ -17,7 +18,10 @@ export async function startTestJobServer(port = 0) {
17
18
  return {
18
19
  url,
19
20
  jobUrl: `${url}/jobs/software-engineer`,
21
+ quickApplyUrl: `${url}/jobs/quick-apply`,
22
+ searchUrl: `${url}/search`,
20
23
  submissions,
24
+ searches,
21
25
  async close() {
22
26
  await new Promise((resolve, reject) => {
23
27
  server.close((error) => (error ? reject(error) : resolve()));
@@ -36,7 +40,7 @@ export async function startTestJobServer(port = 0) {
36
40
  },
37
41
  };
38
42
  }
39
- async function route(req, res, submissions) {
43
+ async function route(req, res, submissions, searches) {
40
44
  const url = new URL(req.url || "/", "http://127.0.0.1");
41
45
  if (req.method === "GET" && url.pathname === "/") {
42
46
  redirect(res, "/jobs/software-engineer");
@@ -51,6 +55,17 @@ async function route(req, res, submissions) {
51
55
  }));
52
56
  return;
53
57
  }
58
+ if (req.method === "GET" && url.pathname === "/jobs/quick-apply") {
59
+ html(res, 200, quickApplyPage());
60
+ return;
61
+ }
62
+ if (req.method === "GET" && url.pathname === "/search") {
63
+ const q = url.searchParams.get("q");
64
+ if (q !== null)
65
+ searches.push(q);
66
+ html(res, 200, searchPage(q));
67
+ return;
68
+ }
54
69
  if (req.method === "POST" && url.pathname === "/apply") {
55
70
  const submission = await parseApplication(req);
56
71
  submissions.unshift(submission);
@@ -125,6 +140,33 @@ function jobPage(opts = {}) {
125
140
  </main>
126
141
  `);
127
142
  }
143
+ /** One click = one real application. No `required` attribute anywhere, so nothing but the guard
144
+ * can stop the POST — a fixture where HTML validation did the stopping would prove nothing. */
145
+ function quickApplyPage() {
146
+ return page("Postuler en 1 clic", `
147
+ <main>
148
+ <section class="job"><h1>Ingénieur logiciel</h1><p class="company">Fixture Labs</p></section>
149
+ <form action="/apply" method="post" enctype="multipart/form-data" class="application-form">
150
+ <input type="hidden" name="fullName" value="Sergey Ivochkin" />
151
+ <input type="hidden" name="email" value="sergey@example.com" />
152
+ <label>Recherche <input name="coverNote" aria-label="Recherche" /></label>
153
+ <button type="submit">Envoyer ma candidature</button>
154
+ </form>
155
+ </main>
156
+ `);
157
+ }
158
+ /** A GET search form: submitting it only READS. */
159
+ function searchPage(q) {
160
+ return page("Recherche", `
161
+ <main>
162
+ <form action="/search" method="get">
163
+ <label>Search <input name="q" aria-label="Search" /></label>
164
+ <button type="submit">Search</button>
165
+ </form>
166
+ <p id="result">${q === null ? "no query" : `searched: ${escapeHtml(q)}`}</p>
167
+ </main>
168
+ `);
169
+ }
128
170
  function successPage(submission) {
129
171
  return page("Application Received", `
130
172
  <main>
package/dist/index.js CHANGED
@@ -1217,6 +1217,9 @@ function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */
1217
1217
  function shouldRegisterOnOpen(reconnect, alreadyRegistered) {
1218
1218
  return reconnect || !alreadyRegistered;
1219
1219
  }
1220
+ function pendingRegistrations(attached, registered) {
1221
+ return [...attached].filter((id) => !registered.has(id));
1222
+ }
1220
1223
  function instanceLabel(inst) {
1221
1224
  const short = `${inst.id.slice(0, 8)}\u2026`;
1222
1225
  return inst.name ? `${inst.name} (${short})` : short;
@@ -1336,7 +1339,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1336
1339
  };
1337
1340
  for (const id of instanceIds) attach(id, "");
1338
1341
  reportRegistration();
1339
- writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect.`);
1342
+ writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect${watchInstances ? " and every 20s while this runs" : ""}.`);
1340
1343
  writeLine("");
1341
1344
  writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
1342
1345
  writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
@@ -1406,6 +1409,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
1406
1409
  attach(inst.id, instanceLabel(inst));
1407
1410
  }
1408
1411
  for (const id of toDetach) detach(id);
1412
+ const pending = pendingRegistrations(attached.keys(), registered);
1413
+ if (pending.length) {
1414
+ for (const id of pending) await registerRuntime(id);
1415
+ reportRegistration();
1416
+ }
1409
1417
  } catch {
1410
1418
  }
1411
1419
  tick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.52",
3
+ "version": "0.4.54",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",