@proagentstore/cli 0.4.78 → 0.4.80
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.
- package/dist/browser-runner/local-apply/bridge.js +442 -0
- package/dist/browser-runner/local-apply/contract.js +165 -0
- package/dist/browser-runner/local-apply/engine.js +50 -0
- package/dist/browser-runner/local-apply/runtime.js +497 -0
- package/dist/browser-runner/local-artifact/runtime.js +1 -1
- package/dist/browser-runner/local-browser/bridge.js +3 -0
- package/dist/browser-runner/local-browser/engine.js +12 -1
- package/dist/browser-runner/local-browser/runtime.js +6 -1
- package/dist/browser-runner/runner.js +17 -14
- package/dist/browser-runner/server.js +18 -1
- package/package.json +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply bridge (#957): the only way an application CLI reaches the page, and where the write
|
|
3
|
+
* policy is enforced. Like the research bridge (`local-browser/bridge.ts`), the CLI gets no web
|
|
4
|
+
* access of its own and exactly one MCP server, whose calls land HERE, in the runner.
|
|
5
|
+
*
|
|
6
|
+
* Every call is put in an action class (see contract.ts) and allowed or refused by code:
|
|
7
|
+
*
|
|
8
|
+
* - read: navigate / back / snapshot / wait, and clicks on links, tabs and pagination — on the
|
|
9
|
+
* allowed sites only. Leaving them pauses the run (`external_redirect`).
|
|
10
|
+
* - fill: `browser_type`, `browser_select_option`, and clicks on checkboxes, radios and options.
|
|
11
|
+
* Each carries a `source_quote` that must be verbatim text from the owner's profile, answers or
|
|
12
|
+
* approved materials, and the value must appear in that quote — a form answer not grounded in
|
|
13
|
+
* the profile is refused, and the CLI is told to ask (`request_answer`). Uploads go through
|
|
14
|
+
* `upload_artifact`, which can only attach an approved artifact whose hash still matches, once.
|
|
15
|
+
* - submit: any click on a control that submits a POST form (a DOM fact, read from the page by
|
|
16
|
+
* the commit-guard probe) or whose own accessible name commits, in any of the guard's
|
|
17
|
+
* languages. Under `fill_and_review` it is NEVER performed: the run stops `awaiting_review`.
|
|
18
|
+
* Under `auto_submit` it is performed at most once, after a fresh blocker check, between a
|
|
19
|
+
* `submit.attempted` and a `submit.confirmed` / `submit.unconfirmed` trace event.
|
|
20
|
+
*
|
|
21
|
+
* Enter, Space, typed characters by key, scripts, drag, dialogs, `browser_fill_form` and raw
|
|
22
|
+
* `browser_file_upload` are refused by name. A captcha, a sign-in, a consent box, a duplicate
|
|
23
|
+
* application or an anti-bot wall pauses the run for a person; nothing here works around one.
|
|
24
|
+
*
|
|
25
|
+
* The trace carries classes, domains, decisions and artifact handles — never a typed value.
|
|
26
|
+
*/
|
|
27
|
+
import { ELEMENT_PROBE_FN, FALLBACK_COMMIT_RE } from "../commit-guard.js";
|
|
28
|
+
import { normalize } from "../local-artifact/engine.js";
|
|
29
|
+
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
30
|
+
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
|
|
31
|
+
const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
|
|
32
|
+
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review"];
|
|
33
|
+
const REFUSED = new Set([
|
|
34
|
+
"browser_fill_form",
|
|
35
|
+
"browser_file_upload",
|
|
36
|
+
"browser_evaluate",
|
|
37
|
+
"browser_run_code",
|
|
38
|
+
"browser_drag",
|
|
39
|
+
"browser_hover",
|
|
40
|
+
"browser_handle_dialog",
|
|
41
|
+
"browser_install",
|
|
42
|
+
"browser_tabs",
|
|
43
|
+
"browser_close",
|
|
44
|
+
]);
|
|
45
|
+
/** Every tool name the bridge can list — what a CLI may be told to trust without asking. */
|
|
46
|
+
export const APPLY_BRIDGE_TOOL_NAMES = [...READ_TOOLS, "browser_click", ...FILL_TOOLS, "browser_press_key", ...BRIDGE_TOOLS];
|
|
47
|
+
/** Roles whose click is an ANSWER (a fill), never a navigation or a submit. */
|
|
48
|
+
const FIELD_ROLES = new Set(["checkbox", "radio", "option", "switch", "menuitemradio", "menuitemcheckbox"]);
|
|
49
|
+
const TYPE_ROLES = new Set(["textbox", "searchbox", "combobox", "spinbutton"]);
|
|
50
|
+
const NAV_ROLES = new Set(["link", "tab", "menuitem", "treeitem"]);
|
|
51
|
+
const PAGINATION_NAME = /^(next|previous|prev|more|load more|show more|see more|view more|page \d+|\d+|›|»|‹|«)( page| results| jobs)?$/i;
|
|
52
|
+
/** Buttons that move within a form or open a file chooser — allowed when they do NOT submit. */
|
|
53
|
+
const STEP_NAME = /^(next|continue|back|previous|add( another)?|upload|attach|browse|choose( a)? file|select( a)? file|remove|edit|expand|collapse|show more)\b/i;
|
|
54
|
+
/** A tick that is a legal or privacy agreement: the owner's call, not the CLI's. */
|
|
55
|
+
const CONSENT_NAME = /\b(agree|consent|terms|privacy|acknowledge|certify|accept|declaration|gdpr)\b/i;
|
|
56
|
+
/** A field that is a credential: a sign-in, never an application answer. */
|
|
57
|
+
const CREDENTIAL_NAME = /password|passcode|one[- ]?time|\botp\b|verification code|security code|2fa|two[- ]factor/i;
|
|
58
|
+
/** Keys that move between fields without entering or activating anything. */
|
|
59
|
+
const SAFE_KEYS = /^(Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Escape|Home|End|PageUp|PageDown)$/;
|
|
60
|
+
/** Runs inside the page, through the browser's own evaluate tool — never offered to the CLI. */
|
|
61
|
+
const INSPECT_PAGE = `() => {
|
|
62
|
+
const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
|
|
63
|
+
const has = (s) => !!document.querySelector(s);
|
|
64
|
+
return {
|
|
65
|
+
url: location.href,
|
|
66
|
+
title: document.title.slice(0, 200),
|
|
67
|
+
captcha: has('iframe[src*="recaptcha"], .g-recaptcha, iframe[src*="hcaptcha.com"], .h-captcha, iframe[src*="challenges.cloudflare.com"], .cf-turnstile, iframe[src*="arkoselabs"], .geetest_holder')
|
|
68
|
+
|| /confirm (that )?you('?re| are) not a robot|i'?m not a robot|verify (that )?you('?re| are) (a )?human/.test(text),
|
|
69
|
+
login: has('input[type=password]'),
|
|
70
|
+
duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
|
|
71
|
+
antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
|
|
72
|
+
confirmed: /thank you for (your )?appl|application (has been |was )?(received|submitted)|we('ve| have) received your application|successfully (applied|submitted)/.test(text),
|
|
73
|
+
};
|
|
74
|
+
}`;
|
|
75
|
+
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
|
|
76
|
+
const textOf = (r) => (r.content ?? []).map((c) => c.text ?? "").join("\n");
|
|
77
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
78
|
+
/**
|
|
79
|
+
* Is `value` grounded? The quote must be verbatim text from the owner's sources, and the value must
|
|
80
|
+
* appear in the quote as a whole phrase ("No" is not found inside "Notice"). Returns the refusal,
|
|
81
|
+
* or null. Pure, so the rule is tested as a rule.
|
|
82
|
+
*/
|
|
83
|
+
export function groundingRefusal(value, quote, grounding) {
|
|
84
|
+
const q = typeof quote === "string" ? normalize(quote) : "";
|
|
85
|
+
if (q.length < 2)
|
|
86
|
+
return "Every answer needs a source_quote: the exact text from the owner's profile, answers or materials that this value comes from.";
|
|
87
|
+
if (!normalize(grounding).includes(q))
|
|
88
|
+
return "The source_quote is not text from the owner's profile, answers or materials. Quote them exactly, or call request_answer.";
|
|
89
|
+
const v = normalize(value);
|
|
90
|
+
if (!v)
|
|
91
|
+
return "The value is empty.";
|
|
92
|
+
if (!new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(v)}(?![\\p{L}\\p{N}])`, "u").test(q))
|
|
93
|
+
return `"${value.slice(0, 80)}" does not appear in the source_quote. Use the owner's value as written, or call request_answer.`;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const QUOTE_PROP = { type: "string", description: "The exact text from the owner's profile, answers or materials that this answer comes from. Required; the value must appear in it." };
|
|
97
|
+
/** A browser tool's schema with `source_quote` added — the CLI must say where an answer comes from. */
|
|
98
|
+
function withQuote(schema, required) {
|
|
99
|
+
const s = (schema && typeof schema === "object" ? schema : { type: "object" });
|
|
100
|
+
return { ...s, type: "object", properties: { ...(s.properties ?? {}), source_quote: QUOTE_PROP }, ...(required ? { required: [...(s.required ?? []), "source_quote"] } : {}) };
|
|
101
|
+
}
|
|
102
|
+
export class ApplyBridge {
|
|
103
|
+
browser;
|
|
104
|
+
host;
|
|
105
|
+
actions = 0;
|
|
106
|
+
pages = 0;
|
|
107
|
+
lastSnapshot = "";
|
|
108
|
+
admitted = new Set();
|
|
109
|
+
filled = 0;
|
|
110
|
+
uploaded = new Set();
|
|
111
|
+
reviewReady = false;
|
|
112
|
+
summary = null;
|
|
113
|
+
submitAttempted = false;
|
|
114
|
+
submitted = null;
|
|
115
|
+
/** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
|
|
116
|
+
blocked = null;
|
|
117
|
+
constructor(browser, host) {
|
|
118
|
+
this.browser = browser;
|
|
119
|
+
this.host = host;
|
|
120
|
+
}
|
|
121
|
+
get done() {
|
|
122
|
+
return this.reviewReady || this.submitAttempted;
|
|
123
|
+
}
|
|
124
|
+
async listTools() {
|
|
125
|
+
const own = (await this.browser.listTools()).filter((t) => READ_TOOLS.has(t.name) || FILL_TOOLS.has(t.name) || t.name === "browser_click" || t.name === "browser_press_key");
|
|
126
|
+
const tools = own.map((t) => {
|
|
127
|
+
if (t.name === "browser_click")
|
|
128
|
+
return { ...t, description: "Click a link, tab, pagination or form-step button, or answer a checkbox / radio / option (answers need source_quote). A control that submits the application is governed by the run's policy.", inputSchema: withQuote(t.inputSchema, false) };
|
|
129
|
+
if (t.name === "browser_press_key")
|
|
130
|
+
return { ...t, description: "Press Tab, Shift+Tab, an arrow key, Escape, Home/End or PageUp/PageDown. Nothing that types or submits." };
|
|
131
|
+
if (FILL_TOOLS.has(t.name))
|
|
132
|
+
return { ...t, inputSchema: withQuote(t.inputSchema, true) };
|
|
133
|
+
return t;
|
|
134
|
+
});
|
|
135
|
+
return [
|
|
136
|
+
...tools,
|
|
137
|
+
{
|
|
138
|
+
name: "upload_artifact",
|
|
139
|
+
description: "Attach an approved document. First click the field's upload control (it opens a file chooser), then call this. Each document can be attached once.",
|
|
140
|
+
inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["resume", "cover_letter"] } }, required: ["kind"] },
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "request_answer",
|
|
144
|
+
description: "Ask the owner for an answer the sources do not give (missing_answer), or when a question is ambiguous (screening_ambiguity). The run waits for them.",
|
|
145
|
+
inputSchema: { type: "object", properties: { question: { type: "string" }, kind: { type: "string", enum: ["missing_answer", "screening_ambiguity"] } }, required: ["question"] },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "ready_for_review",
|
|
149
|
+
description: "The form is filled as far as the sources allow. Stop here; the owner reviews it. Call once, last.",
|
|
150
|
+
inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
async callTool(name, args = {}) {
|
|
155
|
+
if (this.done)
|
|
156
|
+
return text("The application is finished for this run. Stop now.", true);
|
|
157
|
+
if (name === "ready_for_review")
|
|
158
|
+
return this.review(typeof args.summary === "string" ? args.summary : "");
|
|
159
|
+
if (name === "request_answer")
|
|
160
|
+
return this.requestAnswer(args);
|
|
161
|
+
if (REFUSED.has(name))
|
|
162
|
+
return this.refuse(name, "read", `${name} is not available to an application run.`);
|
|
163
|
+
if (!READ_TOOLS.has(name) && !FILL_TOOLS.has(name) && name !== "browser_click" && name !== "browser_press_key" && name !== "upload_artifact")
|
|
164
|
+
return this.refuse(name, "read", `${name} is not available.`);
|
|
165
|
+
if (this.host.overTime())
|
|
166
|
+
return this.refuse(name, "read", "The run's time limit is reached. Call ready_for_review now.");
|
|
167
|
+
if (++this.actions > this.host.limits.maxActions)
|
|
168
|
+
return this.refuse(name, "read", `The run's limit of ${this.host.limits.maxActions} browser actions is reached. Call ready_for_review now.`);
|
|
169
|
+
if (name === "browser_navigate")
|
|
170
|
+
return this.navigate(String(args.url ?? ""));
|
|
171
|
+
if (name === "browser_click")
|
|
172
|
+
return this.click(args);
|
|
173
|
+
if (name === "browser_type")
|
|
174
|
+
return this.type(args);
|
|
175
|
+
if (name === "browser_select_option")
|
|
176
|
+
return this.select(args);
|
|
177
|
+
if (name === "browser_press_key")
|
|
178
|
+
return this.pressKey(args);
|
|
179
|
+
if (name === "upload_artifact")
|
|
180
|
+
return this.upload(args);
|
|
181
|
+
const res = await this.browser.callTool(name, args);
|
|
182
|
+
if (name === "browser_snapshot")
|
|
183
|
+
this.lastSnapshot = textOf(res);
|
|
184
|
+
if (name === "browser_navigate_back") {
|
|
185
|
+
const state = await this.inspect();
|
|
186
|
+
if (state)
|
|
187
|
+
return this.landed(textOf(res), state);
|
|
188
|
+
}
|
|
189
|
+
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
190
|
+
}
|
|
191
|
+
refuse(tool, cls, reason, domain) {
|
|
192
|
+
this.host.emit({ type: "policy.decision", ...(domain ? { domain } : {}), detail: { tool, class: cls, decision: "refused", reason: reason.slice(0, 200) } });
|
|
193
|
+
return text(reason, true);
|
|
194
|
+
}
|
|
195
|
+
allow(tool, cls) {
|
|
196
|
+
this.host.emit({ type: "policy.decision", detail: { tool, class: cls, decision: "allowed" } });
|
|
197
|
+
}
|
|
198
|
+
async inspect() {
|
|
199
|
+
const res = await this.browser.callTool("browser_evaluate", { function: INSPECT_PAGE }).catch(() => null);
|
|
200
|
+
const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
|
|
201
|
+
if (!v || typeof v.url !== "string")
|
|
202
|
+
return null;
|
|
203
|
+
return { url: v.url, title: String(v.title ?? ""), captcha: !!v.captcha, login: !!v.login, duplicate: !!v.duplicate, antiBot: !!v.antiBot, confirmed: !!v.confirmed };
|
|
204
|
+
}
|
|
205
|
+
blockerOf(s) {
|
|
206
|
+
return s.captcha ? "captcha" : s.antiBot ? "anti_bot" : s.duplicate ? "duplicate_application" : s.login ? "login_required" : null;
|
|
207
|
+
}
|
|
208
|
+
/** Pause for the owner; true when they resolved it and work may continue. */
|
|
209
|
+
async pauseFor(p, recheck) {
|
|
210
|
+
this.blocked = { reason: p.reason, questions: p.question ? [p.question] : [] };
|
|
211
|
+
const outcome = await this.host.pause(p);
|
|
212
|
+
if (outcome !== "resumed")
|
|
213
|
+
return false;
|
|
214
|
+
if (recheck && !(await recheck()))
|
|
215
|
+
return false;
|
|
216
|
+
this.blocked = null;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
/** After a page change: count it, trace it, and stop for a person when the page needs one. */
|
|
220
|
+
async landed(toolText, known) {
|
|
221
|
+
const state = known ?? (await this.inspect());
|
|
222
|
+
if (!state)
|
|
223
|
+
return text(toolText);
|
|
224
|
+
this.pages++;
|
|
225
|
+
const host = hostOf(state.url) ?? "";
|
|
226
|
+
this.host.emit({ type: "browser.navigated", url: state.url, domain: host });
|
|
227
|
+
const blocker = this.blockerOf(state);
|
|
228
|
+
if (!blocker)
|
|
229
|
+
return text(toolText);
|
|
230
|
+
this.host.emit({ type: "browser.blocked", url: state.url, domain: host, detail: { reason: blocker } });
|
|
231
|
+
const ok = await this.pauseFor({ reason: blocker, url: state.url, domain: host }, async () => {
|
|
232
|
+
const after = await this.inspect();
|
|
233
|
+
return !!after && this.blockerOf(after) === null;
|
|
234
|
+
});
|
|
235
|
+
if (!ok)
|
|
236
|
+
return text(`${host} needs a person (${blocker.replace(/_/g, " ")}) and it was not resolved. Do not work around it. Stop now.`, true);
|
|
237
|
+
return text("Resumed after the owner handled it. Take a browser_snapshot to read the page.");
|
|
238
|
+
}
|
|
239
|
+
/** May the run be on this host? Asks the owner (external_redirect) when it is not an allowed site. */
|
|
240
|
+
async admit(host, url) {
|
|
241
|
+
if (this.host.isAllowed(host)) {
|
|
242
|
+
if (!this.admitted.has(host)) {
|
|
243
|
+
this.admitted.add(host);
|
|
244
|
+
this.host.emit({ type: "policy.decision", domain: host, detail: { class: "read", decision: "allowed", basis: "allow_list" } });
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return this.pauseFor({ reason: "external_redirect", url, domain: host }, async () => this.host.isAllowed(host));
|
|
249
|
+
}
|
|
250
|
+
async navigate(url) {
|
|
251
|
+
const host = hostOf(url);
|
|
252
|
+
if (!host)
|
|
253
|
+
return this.refuse("browser_navigate", "read", "Only http(s) URLs can be opened.");
|
|
254
|
+
if (this.pages >= this.host.limits.maxPages)
|
|
255
|
+
return this.refuse("browser_navigate", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
|
|
256
|
+
if (!(await this.admit(host, url)))
|
|
257
|
+
return this.refuse("browser_navigate", "read", `${host} is not one of this application's sites and the owner did not allow it. Stop now.`, host);
|
|
258
|
+
const res = await this.browser.callTool("browser_navigate", { url });
|
|
259
|
+
if (res.isError)
|
|
260
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
261
|
+
return this.afterMove("browser_navigate", textOf(res));
|
|
262
|
+
}
|
|
263
|
+
/** A page change the CLI did not fully choose (a redirect, a link): leaving the allowed sites is undone and paused. */
|
|
264
|
+
async afterMove(tool, toolText) {
|
|
265
|
+
const state = await this.inspect();
|
|
266
|
+
const landedOn = state ? hostOf(state.url) : null;
|
|
267
|
+
if (state && landedOn && !this.host.isAllowed(landedOn)) {
|
|
268
|
+
await this.browser.callTool("browser_navigate_back", {}).catch(() => undefined);
|
|
269
|
+
if (!(await this.pauseFor({ reason: "external_redirect", url: state.url, domain: landedOn }, async () => this.host.isAllowed(landedOn)))) {
|
|
270
|
+
return this.refuse(tool, "read", `That led to ${landedOn}, which is not one of this application's sites. It was undone. Stop now.`, landedOn);
|
|
271
|
+
}
|
|
272
|
+
return text(`The owner allowed ${landedOn}. Open it with browser_navigate.`);
|
|
273
|
+
}
|
|
274
|
+
return this.landed(toolText, state);
|
|
275
|
+
}
|
|
276
|
+
target(args) {
|
|
277
|
+
const ref = typeof args.target === "string" ? args.target : typeof args.ref === "string" ? args.ref : "";
|
|
278
|
+
const found = ref ? refRole(this.lastSnapshot, ref) : null;
|
|
279
|
+
return found ? { ref, ...found } : null;
|
|
280
|
+
}
|
|
281
|
+
async probe(ref, label) {
|
|
282
|
+
const res = await this.browser.callTool("browser_evaluate", { element: label || "control", target: ref, function: ELEMENT_PROBE_FN }).catch(() => null);
|
|
283
|
+
const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
|
|
284
|
+
return v && typeof v.submits === "boolean" ? v : null;
|
|
285
|
+
}
|
|
286
|
+
async click(args) {
|
|
287
|
+
const t = this.target(args);
|
|
288
|
+
if (!t)
|
|
289
|
+
return this.refuse("browser_click", "read", "Click only elements from the latest browser_snapshot, by their ref.");
|
|
290
|
+
const forward = { element: typeof args.element === "string" ? args.element : t.name, target: t.ref };
|
|
291
|
+
if (FIELD_ROLES.has(t.role)) {
|
|
292
|
+
if ((t.role === "checkbox" || t.role === "switch") && CONSENT_NAME.test(t.name)) {
|
|
293
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "fill", decision: "paused", reason: "consent_required" } });
|
|
294
|
+
const ok = await this.pauseFor({ reason: "consent_required", question: `Agree to: ${t.name.slice(0, 200)}` });
|
|
295
|
+
return ok ? text("The owner handled that agreement in the browser. Do not click it yourself; take a browser_snapshot and continue.") : text("The owner did not handle that agreement. Stop now.", true);
|
|
296
|
+
}
|
|
297
|
+
const refusal = groundingRefusal(t.name, args.source_quote, this.host.grounding());
|
|
298
|
+
if (refusal)
|
|
299
|
+
return this.refuse("browser_click", "fill", refusal);
|
|
300
|
+
return this.fill("browser_click", t.role, forward);
|
|
301
|
+
}
|
|
302
|
+
const facts = await this.probe(t.ref, t.name);
|
|
303
|
+
if (!facts)
|
|
304
|
+
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.");
|
|
305
|
+
const commits = (facts.submits && facts.method === "post") || [facts.name, t.name, String(forward.element)].some((n) => n && FALLBACK_COMMIT_RE.test(n));
|
|
306
|
+
if (commits)
|
|
307
|
+
return this.submit(forward);
|
|
308
|
+
if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
|
|
309
|
+
if (this.pages >= this.host.limits.maxPages)
|
|
310
|
+
return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
|
|
311
|
+
const res = await this.browser.callTool("browser_click", forward);
|
|
312
|
+
if (res.isError)
|
|
313
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
314
|
+
return this.afterMove("browser_click", textOf(res));
|
|
315
|
+
}
|
|
316
|
+
// A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
|
|
317
|
+
const fileInput = facts.tag === "input" && facts.type === "file";
|
|
318
|
+
if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
|
|
319
|
+
this.allow("browser_click", "fill");
|
|
320
|
+
const res = await this.browser.callTool("browser_click", forward);
|
|
321
|
+
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
322
|
+
}
|
|
323
|
+
return this.refuse("browser_click", "read", `"${t.name || t.role}" is not a control an application run may press. Links, form-step buttons (Next, Upload, Add) and answers are allowed.`);
|
|
324
|
+
}
|
|
325
|
+
async fill(tool, role, forward) {
|
|
326
|
+
const res = await this.browser.callTool(tool, forward);
|
|
327
|
+
if (res.isError)
|
|
328
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
329
|
+
this.filled++;
|
|
330
|
+
this.host.emit({ type: "field.filled", detail: { tool, class: "fill", role } });
|
|
331
|
+
return text(textOf(res));
|
|
332
|
+
}
|
|
333
|
+
async type(args) {
|
|
334
|
+
const t = this.target(args);
|
|
335
|
+
if (!t || !TYPE_ROLES.has(t.role))
|
|
336
|
+
return this.refuse("browser_type", "fill", "Type only into a text field from the latest browser_snapshot, by its ref.");
|
|
337
|
+
if (args.submit === true)
|
|
338
|
+
return this.refuse("browser_type", "submit", "Typing may not submit the form (submit: true is refused).");
|
|
339
|
+
if (CREDENTIAL_NAME.test(t.name)) {
|
|
340
|
+
const ok = await this.pauseFor({ reason: "login_required", question: `Sign in: ${t.name.slice(0, 200)}` });
|
|
341
|
+
return ok ? text("The owner handled the sign-in. Take a browser_snapshot and continue.") : text("A sign-in needs the owner and was not handled. Stop now.", true);
|
|
342
|
+
}
|
|
343
|
+
const value = typeof args.text === "string" ? args.text : "";
|
|
344
|
+
const refusal = groundingRefusal(value, args.source_quote, this.host.grounding());
|
|
345
|
+
if (refusal)
|
|
346
|
+
return this.refuse("browser_type", "fill", refusal);
|
|
347
|
+
return this.fill("browser_type", t.role, { element: typeof args.element === "string" ? args.element : t.name, target: t.ref, text: value });
|
|
348
|
+
}
|
|
349
|
+
async select(args) {
|
|
350
|
+
const t = this.target(args);
|
|
351
|
+
if (!t)
|
|
352
|
+
return this.refuse("browser_select_option", "fill", "Select only in a field from the latest browser_snapshot, by its ref.");
|
|
353
|
+
const values = Array.isArray(args.values) ? args.values.filter((v) => typeof v === "string") : [];
|
|
354
|
+
if (!values.length)
|
|
355
|
+
return this.refuse("browser_select_option", "fill", "values is required.");
|
|
356
|
+
for (const v of values) {
|
|
357
|
+
const refusal = groundingRefusal(v, args.source_quote, this.host.grounding());
|
|
358
|
+
if (refusal)
|
|
359
|
+
return this.refuse("browser_select_option", "fill", refusal);
|
|
360
|
+
}
|
|
361
|
+
return this.fill("browser_select_option", t.role, { element: typeof args.element === "string" ? args.element : t.name, target: t.ref, values });
|
|
362
|
+
}
|
|
363
|
+
async pressKey(args) {
|
|
364
|
+
const key = typeof args.key === "string" ? args.key : "";
|
|
365
|
+
if (!SAFE_KEYS.test(key))
|
|
366
|
+
return this.refuse("browser_press_key", "submit", `${key || "That key"} is refused: only Tab, arrows, Escape, Home/End and PageUp/PageDown move between fields. Enter and Space can submit.`);
|
|
367
|
+
const res = await this.browser.callTool("browser_press_key", { key });
|
|
368
|
+
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
369
|
+
}
|
|
370
|
+
async upload(args) {
|
|
371
|
+
const kind = args.kind === "resume" || args.kind === "cover_letter" ? args.kind : null;
|
|
372
|
+
if (!kind)
|
|
373
|
+
return this.refuse("upload_artifact", "fill", "kind must be resume or cover_letter.");
|
|
374
|
+
if (this.uploaded.has(kind))
|
|
375
|
+
return this.refuse("upload_artifact", "fill", `The ${kind.replace("_", " ")} is already attached; it is never attached twice.`);
|
|
376
|
+
const file = this.host.artifactPath(kind);
|
|
377
|
+
if ("error" in file) {
|
|
378
|
+
this.blocked = { reason: "artifact_changed", questions: [file.error] };
|
|
379
|
+
return this.refuse("upload_artifact", "fill", `${file.error} Stop now.`);
|
|
380
|
+
}
|
|
381
|
+
const res = await this.browser.callTool("browser_file_upload", { paths: [file.path] });
|
|
382
|
+
if (res.isError) {
|
|
383
|
+
const msg = textOf(res);
|
|
384
|
+
return text(/modal state|file chooser/i.test(msg) ? "No file chooser is open. Click the field's upload control first, then call upload_artifact." : msg, true);
|
|
385
|
+
}
|
|
386
|
+
this.uploaded.add(kind);
|
|
387
|
+
this.host.emit({ type: "artifact.uploaded", detail: { kind, sha256: file.sha256, class: "fill" } });
|
|
388
|
+
return text(`Attached the approved ${kind.replace("_", " ")}.`);
|
|
389
|
+
}
|
|
390
|
+
async requestAnswer(args) {
|
|
391
|
+
const question = typeof args.question === "string" ? args.question.trim().slice(0, 300) : "";
|
|
392
|
+
if (!question)
|
|
393
|
+
return text("request_answer needs the question.", true);
|
|
394
|
+
const reason = args.kind === "screening_ambiguity" ? "screening_ambiguity" : "missing_answer";
|
|
395
|
+
const before = this.host.grounding().length;
|
|
396
|
+
const ok = await this.pauseFor({ reason, question });
|
|
397
|
+
if (!ok)
|
|
398
|
+
return text("Nobody answered. Do not guess. Stop now.", true);
|
|
399
|
+
// Resumed without an answer: the owner chose to leave it — never a cue to guess.
|
|
400
|
+
if (this.host.grounding().length === before)
|
|
401
|
+
return text("The owner resumed without answering: leave that field empty and continue with the others.");
|
|
402
|
+
return text("The owner answered. Their answer is now part of your sources — quote it as source_quote. Take a browser_snapshot and continue.");
|
|
403
|
+
}
|
|
404
|
+
review(summary) {
|
|
405
|
+
this.reviewReady = true;
|
|
406
|
+
this.summary = summary; // bounded where the result is built (runtime.ts `end`)
|
|
407
|
+
this.host.emit({ type: "review.ready", detail: { class: "review", count: this.filled } });
|
|
408
|
+
return text("Recorded. The application waits for the owner's review; nothing was submitted. Stop now.");
|
|
409
|
+
}
|
|
410
|
+
/** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
|
|
411
|
+
async submit(forward) {
|
|
412
|
+
if (this.host.mode !== "auto_submit" || !this.host.gateId) {
|
|
413
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
|
|
414
|
+
this.review("Stopped at the final submit: this run fills and waits for review.");
|
|
415
|
+
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.");
|
|
416
|
+
}
|
|
417
|
+
const before = await this.inspect();
|
|
418
|
+
if (!before)
|
|
419
|
+
return this.refuse("browser_click", "submit", "The page could not be read, so the submit cannot be shown to be safe. Take a fresh browser_snapshot.");
|
|
420
|
+
const blocker = this.blockerOf(before);
|
|
421
|
+
if (blocker) {
|
|
422
|
+
this.blocked = { reason: blocker, questions: [] };
|
|
423
|
+
return this.refuse("browser_click", "submit", `The page shows a blocker (${blocker.replace(/_/g, " ")}); nothing was submitted. Stop now.`, hostOf(before.url) ?? undefined);
|
|
424
|
+
}
|
|
425
|
+
const domain = hostOf(before.url) ?? "";
|
|
426
|
+
if (!this.host.isAllowed(domain))
|
|
427
|
+
return this.refuse("browser_click", "submit", `${domain} is not one of this application's sites; nothing was submitted.`, domain);
|
|
428
|
+
this.submitAttempted = true;
|
|
429
|
+
this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
|
|
430
|
+
const res = await this.browser.callTool("browser_click", forward);
|
|
431
|
+
await this.browser.callTool("browser_wait_for", { time: 2 }).catch(() => undefined);
|
|
432
|
+
const after = res.isError ? null : await this.inspect();
|
|
433
|
+
if (after?.confirmed) {
|
|
434
|
+
this.submitted = { url: after.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
|
|
435
|
+
this.host.emit({ type: "submit.confirmed", url: after.url, domain: hostOf(after.url) ?? domain, detail: { gateId: this.host.gateId } });
|
|
436
|
+
return text("Submitted, and the site confirmed it. Stop now.");
|
|
437
|
+
}
|
|
438
|
+
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."] };
|
|
439
|
+
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
|
|
440
|
+
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local application execution — the wire contract between PAGS and the runner (#957, epic #943).
|
|
3
|
+
*
|
|
4
|
+
* The Job Application Runner takes ONE application whose materials are ready (#956) and fills its
|
|
5
|
+
* form in a real browser on the owner's machine, driven by a Codex or Claude Code CLI signed in on
|
|
6
|
+
* that machine. The CLI reaches the page only through the runner's apply bridge, which enforces the
|
|
7
|
+
* write policy below: every browser action belongs to an ACTION CLASS, and a class is allowed or
|
|
8
|
+
* refused by the runner, not asked of the model.
|
|
9
|
+
*
|
|
10
|
+
* read — navigate, snapshot, wait, follow a link inside the allowed sites;
|
|
11
|
+
* fill — type, select, tick, upload an approved artifact — each value grounded in the owner's
|
|
12
|
+
* own profile / answers / artifacts;
|
|
13
|
+
* review — stop with the form filled and nothing sent (`awaiting_review`);
|
|
14
|
+
* submit — the final external Submit. Only under `auto_submit`, only with the gate PAGS
|
|
15
|
+
* evaluated (`policy.submitGate`), and at most once per run.
|
|
16
|
+
*
|
|
17
|
+
* The default mode is `fill_and_review`, in which a submit-class action is never performed.
|
|
18
|
+
*
|
|
19
|
+
* Kept BYTE-IDENTICAL at
|
|
20
|
+
*
|
|
21
|
+
* workers/api/src/lib/local-apply/contract.ts
|
|
22
|
+
* packages/browser-runner/src/local-apply/contract.ts
|
|
23
|
+
*
|
|
24
|
+
* (`contract.test.ts` fails the moment they differ) and imports nothing, so it can be vendored.
|
|
25
|
+
*
|
|
26
|
+
* PULL, not push: the runner holds no API token, so PAGS reads a run with `status` — when it is
|
|
27
|
+
* read and from the per-minute cron — exactly like local browser research.
|
|
28
|
+
*/
|
|
29
|
+
export const LOCAL_APPLY_TASK_TYPE = "local_browser.apply";
|
|
30
|
+
export const LOCAL_APPLY_RUN_PATH = "/local-apply/run";
|
|
31
|
+
export const LOCAL_APPLY_STATUS_PATH = "/local-apply/status";
|
|
32
|
+
export const LOCAL_APPLY_RESUME_PATH = "/local-apply/resume";
|
|
33
|
+
export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
|
|
34
|
+
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
35
|
+
export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
|
|
36
|
+
export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
|
|
37
|
+
export const LOCAL_APPLY_PAUSE_REASONS = [
|
|
38
|
+
"captcha",
|
|
39
|
+
"login_required",
|
|
40
|
+
"consent_required",
|
|
41
|
+
"missing_answer",
|
|
42
|
+
"screening_ambiguity",
|
|
43
|
+
"external_redirect",
|
|
44
|
+
"duplicate_application",
|
|
45
|
+
"anti_bot",
|
|
46
|
+
];
|
|
47
|
+
export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
48
|
+
...LOCAL_APPLY_PAUSE_REASONS,
|
|
49
|
+
"submit_unconfirmed",
|
|
50
|
+
"incomplete",
|
|
51
|
+
"engine_not_signed_in",
|
|
52
|
+
"api_key_refused",
|
|
53
|
+
"source_unavailable",
|
|
54
|
+
"artifact_changed",
|
|
55
|
+
];
|
|
56
|
+
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
57
|
+
"engine.auth_checked",
|
|
58
|
+
"engine.started",
|
|
59
|
+
"engine.ended",
|
|
60
|
+
"source.read",
|
|
61
|
+
"browser.navigated",
|
|
62
|
+
"browser.blocked",
|
|
63
|
+
"policy.decision",
|
|
64
|
+
"field.filled",
|
|
65
|
+
"artifact.uploaded",
|
|
66
|
+
"review.ready",
|
|
67
|
+
"submit.attempted",
|
|
68
|
+
"submit.confirmed",
|
|
69
|
+
"submit.unconfirmed",
|
|
70
|
+
"run.paused",
|
|
71
|
+
"run.resumed",
|
|
72
|
+
"note",
|
|
73
|
+
];
|
|
74
|
+
export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
75
|
+
export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
|
|
76
|
+
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
77
|
+
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
78
|
+
const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
79
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis"]);
|
|
80
|
+
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
81
|
+
export function parseLocalApplyEvent(raw) {
|
|
82
|
+
if (!raw || typeof raw !== "object")
|
|
83
|
+
return null;
|
|
84
|
+
const o = raw;
|
|
85
|
+
const type = oneOf(LOCAL_APPLY_EVENT_TYPES, o.type);
|
|
86
|
+
const at = str(o.at, 40);
|
|
87
|
+
if (!type || !at || Number.isNaN(Date.parse(at)))
|
|
88
|
+
return null;
|
|
89
|
+
const event = { type, at };
|
|
90
|
+
const url = str(o.url, 2000);
|
|
91
|
+
if (url && isHttpUrl(url))
|
|
92
|
+
event.url = url;
|
|
93
|
+
const domain = str(o.domain, 253);
|
|
94
|
+
if (domain)
|
|
95
|
+
event.domain = domain.toLowerCase();
|
|
96
|
+
const pauseReason = oneOf(LOCAL_APPLY_PAUSE_REASONS, o.pauseReason);
|
|
97
|
+
if (type === "run.paused" && !pauseReason)
|
|
98
|
+
return null;
|
|
99
|
+
if (pauseReason)
|
|
100
|
+
event.pauseReason = pauseReason;
|
|
101
|
+
if (o.detail && typeof o.detail === "object" && !Array.isArray(o.detail)) {
|
|
102
|
+
const detail = {};
|
|
103
|
+
for (const [k, v] of Object.entries(o.detail)) {
|
|
104
|
+
if (!DETAIL_KEYS.has(k))
|
|
105
|
+
continue;
|
|
106
|
+
if (typeof v === "string")
|
|
107
|
+
detail[k] = v.slice(0, 300);
|
|
108
|
+
else if (typeof v === "number" || typeof v === "boolean")
|
|
109
|
+
detail[k] = v;
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(detail).length)
|
|
112
|
+
event.detail = detail;
|
|
113
|
+
}
|
|
114
|
+
return event;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A validated result envelope, or the reason it is not one. A `submitted` outcome must carry the
|
|
118
|
+
* confirmed page, the gate it ran under, and a run in `auto_submit` — anything less is not a
|
|
119
|
+
* submission PAGS will record.
|
|
120
|
+
*/
|
|
121
|
+
export function parseLocalApplyResult(raw) {
|
|
122
|
+
if (!raw || typeof raw !== "object")
|
|
123
|
+
return { error: "result must be an object" };
|
|
124
|
+
const o = raw;
|
|
125
|
+
const runId = str(o.runId, 100);
|
|
126
|
+
if (!runId)
|
|
127
|
+
return { error: "runId is required" };
|
|
128
|
+
const outcome = oneOf(["awaiting_review", "submitted", "blocked", "failed"], o.outcome);
|
|
129
|
+
if (!outcome)
|
|
130
|
+
return { error: "outcome must be awaiting_review, submitted, blocked or failed" };
|
|
131
|
+
const mode = oneOf(LOCAL_APPLY_MODES, o.mode);
|
|
132
|
+
if (!mode)
|
|
133
|
+
return { error: "mode is required" };
|
|
134
|
+
const traceId = str(o.traceId, 100);
|
|
135
|
+
if (!traceId)
|
|
136
|
+
return { error: "traceId is required" };
|
|
137
|
+
const engineAuth = oneOf(LOCAL_APPLY_ENGINE_AUTHS, o.engineAuth);
|
|
138
|
+
if (!engineAuth)
|
|
139
|
+
return { error: `engineAuth must be one of ${LOCAL_APPLY_ENGINE_AUTHS.join(", ")}` };
|
|
140
|
+
const filled = typeof o.filled === "number" && Number.isInteger(o.filled) && o.filled >= 0 ? o.filled : 0;
|
|
141
|
+
const uploaded = (Array.isArray(o.uploaded) ? o.uploaded : []).filter((k) => k === "resume" || k === "cover_letter");
|
|
142
|
+
const out = { runId, outcome, mode, traceId, engineAuth, filled, uploaded: [...new Set(uploaded)], submitAttempted: o.submitAttempted === true, summary: str(o.summary, LOCAL_APPLY_CAPS.summary) ?? "" };
|
|
143
|
+
if (outcome === "submitted") {
|
|
144
|
+
const s = (o.submitted && typeof o.submitted === "object" ? o.submitted : {});
|
|
145
|
+
const url = str(s.url, 2000);
|
|
146
|
+
const at = str(s.at, 40);
|
|
147
|
+
const gateId = str(s.gateId, 100);
|
|
148
|
+
if (mode !== "auto_submit")
|
|
149
|
+
return { error: "a submitted outcome requires a run in auto_submit mode" };
|
|
150
|
+
if (!url || !isHttpUrl(url) || !at || Number.isNaN(Date.parse(at)) || !gateId)
|
|
151
|
+
return { error: "a submitted outcome needs the confirmed url, its time and the gateId" };
|
|
152
|
+
out.submitted = { url, at, gateId };
|
|
153
|
+
out.submitAttempted = true;
|
|
154
|
+
}
|
|
155
|
+
if (outcome === "blocked") {
|
|
156
|
+
out.blockReason = oneOf(LOCAL_APPLY_BLOCK_REASONS, o.blockReason) ?? "incomplete";
|
|
157
|
+
out.questions = (Array.isArray(o.questions) ? o.questions : [])
|
|
158
|
+
.filter((q) => typeof q === "string" && q.trim() !== "")
|
|
159
|
+
.slice(0, LOCAL_APPLY_CAPS.questions)
|
|
160
|
+
.map((q) => q.trim().slice(0, LOCAL_APPLY_CAPS.questionChars));
|
|
161
|
+
}
|
|
162
|
+
if (outcome === "failed")
|
|
163
|
+
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
164
|
+
return { result: out };
|
|
165
|
+
}
|