@proagentstore/cli 0.4.78 → 0.4.79
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/engine.js +1 -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an application run launches its CLI (#957). The research launch (`local-browser/engine.ts`) —
|
|
3
|
+
* no built-in web or shell tools, the bridge as the only MCP server — with two differences:
|
|
4
|
+
*
|
|
5
|
+
* - Codex pre-approves the APPLY bridge's own tool names, not the research set;
|
|
6
|
+
* - subscription-only, like the Tailor: provider API keys are always stripped, `machine` also
|
|
7
|
+
* drops the Claude subscription token, and the runtime refuses a run whose spawn env would
|
|
8
|
+
* still carry a key.
|
|
9
|
+
*/
|
|
10
|
+
import { artifactEngineEnv, observedArtifactAuth } from "../local-artifact/engine.js";
|
|
11
|
+
import { buildEngineSpec, BRIDGE_SERVER_NAME } from "../local-browser/engine.js";
|
|
12
|
+
import { APPLY_BRIDGE_TOOL_NAMES } from "./bridge.js";
|
|
13
|
+
export { observedArtifactAuth as observedApplyAuth };
|
|
14
|
+
export function buildApplyEngineSpec(e, prompt, bridge, mcpConfigPath) {
|
|
15
|
+
return buildEngineSpec({
|
|
16
|
+
engine: e.engine,
|
|
17
|
+
authMode: e.authMode,
|
|
18
|
+
prompt,
|
|
19
|
+
bridge,
|
|
20
|
+
mcpConfigPath,
|
|
21
|
+
toolTimeoutMs: e.limits.maxMinutes * 60_000,
|
|
22
|
+
baseEnv: artifactEngineEnv(e.authMode),
|
|
23
|
+
toolNames: APPLY_BRIDGE_TOOL_NAMES,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** The brief. The bridge, not this text, is what enforces the rules. */
|
|
27
|
+
export function applyPrompt(e, sources) {
|
|
28
|
+
const t = (name) => `mcp__${BRIDGE_SERVER_NAME}__${name}`;
|
|
29
|
+
const job = [`Title: ${e.job.title}`, e.job.company ? `Company: ${e.job.company}` : "", e.job.location ? `Location: ${e.job.location}` : "", `Application page: ${e.applicationUrl}`].filter(Boolean).join("\n");
|
|
30
|
+
const finish = e.policy.mode === "auto_submit"
|
|
31
|
+
? `- When every field you can ground is filled and the documents are attached, you may press the final submit button ONCE. If anything is uncertain, call ${t("ready_for_review")} instead.`
|
|
32
|
+
: `- Do NOT submit. When every field you can ground is filled and the documents are attached, call ${t("ready_for_review")} with a short summary and stop.`;
|
|
33
|
+
return [
|
|
34
|
+
"You fill in ONE job application for the owner of this machine, in their browser, from their own information.",
|
|
35
|
+
"",
|
|
36
|
+
"<job>",
|
|
37
|
+
job,
|
|
38
|
+
"</job>",
|
|
39
|
+
"",
|
|
40
|
+
...sources.map((s) => `<source name="${s.label}">\n${s.text}\n</source>\n`),
|
|
41
|
+
"How to work:",
|
|
42
|
+
`- Your only tools are from the ${BRIDGE_SERVER_NAME} server. Open the application page with ${t("browser_navigate")}, read it with ${t("browser_snapshot")}, and act on elements by their ref.`,
|
|
43
|
+
`- Every answer — ${t("browser_type")}, ${t("browser_select_option")}, or a click on a checkbox, radio or option — must carry source_quote: the exact text from a source above that the value comes from. Type values exactly as the source writes them.`,
|
|
44
|
+
`- If a question has no answer in the sources, or is ambiguous, call ${t("request_answer")}. Never guess, never invent, never pick a "safe" default.`,
|
|
45
|
+
`- To attach a document, click the field's upload control, then call ${t("upload_artifact")} with kind resume or cover_letter.`,
|
|
46
|
+
"- Never sign in, solve a captcha, accept terms or work around any block; the run pauses for the owner by itself.",
|
|
47
|
+
`- Stay on the application's site. Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
|
|
48
|
+
finish,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local application execution on the runner (#957) — the Job Application Runner's hands.
|
|
3
|
+
*
|
|
4
|
+
* One run = one Codex or Claude Code process, signed in on this machine, filling ONE application
|
|
5
|
+
* through the apply bridge (bridge.ts) in a real browser. The runtime owns what PAGS later pulls
|
|
6
|
+
* with `status`, as local browser research does (`local-browser/runtime.ts`):
|
|
7
|
+
*
|
|
8
|
+
* - idempotency: a start with a requestId this runner already holds returns that run, so a
|
|
9
|
+
* replayed materials_ready event can never fill — or upload, or submit — a second time;
|
|
10
|
+
* - one active application run per instance;
|
|
11
|
+
* - the owner's answer sources and approved artifacts, read and hashed HERE before the CLI starts.
|
|
12
|
+
* An artifact whose hash no longer matches what #956 reported is refused, not uploaded;
|
|
13
|
+
* - pauses: the bridge holds a call until `resume` (with the owner's answer or a newly allowed
|
|
14
|
+
* site), cancel, or the time limit — active time only, so waiting on the owner is not counted;
|
|
15
|
+
* - the result: `awaiting_review`, `submitted` (only with the site's confirmation), `blocked`
|
|
16
|
+
* with the reason, or `failed`.
|
|
17
|
+
*
|
|
18
|
+
* Runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS reads as
|
|
19
|
+
* "the runner lost this run".
|
|
20
|
+
*/
|
|
21
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
22
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join, resolve, sep } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { RunnerInputError } from "../errors.js";
|
|
28
|
+
import { isHomeRelative, isWorkspaceRelative } from "../local-artifact/contract.js";
|
|
29
|
+
import { resolveSource } from "../local-artifact/runtime.js";
|
|
30
|
+
import { domainWithin } from "../local-browser/bridge.js";
|
|
31
|
+
import { BRIDGE_ENV } from "../local-browser/bridge-stdio.js";
|
|
32
|
+
import { redactText, secretEnvValues } from "../local-browser/contract.js";
|
|
33
|
+
import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
|
|
34
|
+
import { resolveWorkspacePath } from "../local-browser/runtime.js";
|
|
35
|
+
import { ApplyBridge } from "./bridge.js";
|
|
36
|
+
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
|
|
37
|
+
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
38
|
+
const MAX_EVENTS = 1000;
|
|
39
|
+
const OUTPUT_LINES = 400;
|
|
40
|
+
const SOURCE_BYTES = 256 * 1024;
|
|
41
|
+
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
|
|
42
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
43
|
+
/** One application at a time per agent: two runs on one employer's form is how duplicates happen. */
|
|
44
|
+
const MAX_ACTIVE = 1;
|
|
45
|
+
class Blocked extends Error {
|
|
46
|
+
reason;
|
|
47
|
+
questions;
|
|
48
|
+
constructor(reason, questions) {
|
|
49
|
+
super(reason);
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
this.questions = questions;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const posInt = (v, max) => (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max ? v : null);
|
|
55
|
+
const strList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").map((x) => x.trim().toLowerCase()).filter(Boolean) : []);
|
|
56
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
57
|
+
/** The envelope, validated at the runner's boundary. */
|
|
58
|
+
export function parseApplyEnvelope(raw) {
|
|
59
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
60
|
+
const bad = (what) => new RunnerInputError(`Invalid application task: ${what}`);
|
|
61
|
+
if (o.type !== LOCAL_APPLY_TASK_TYPE)
|
|
62
|
+
throw bad(`type must be ${LOCAL_APPLY_TASK_TYPE}`);
|
|
63
|
+
for (const k of ["runId", "requestId", "instanceId", "applicationId"])
|
|
64
|
+
if (typeof o[k] !== "string" || !SAFE_ID.test(o[k]))
|
|
65
|
+
throw bad(`${k} is required`);
|
|
66
|
+
if (!/^[A-Za-z0-9_-]{1,100}$/.test(o.runId))
|
|
67
|
+
throw bad("runId must be a plain id");
|
|
68
|
+
if (!LOCAL_APPLY_ENGINES.includes(o.engine))
|
|
69
|
+
throw bad("engine must be claude or codex");
|
|
70
|
+
if (!LOCAL_APPLY_AUTH_MODES.includes(o.authMode))
|
|
71
|
+
throw bad("authMode must be machine or subscription — a provider API key is never used");
|
|
72
|
+
if (o.browserProfile !== "isolated" && o.browserProfile !== "default")
|
|
73
|
+
throw bad("browserProfile must be isolated or default");
|
|
74
|
+
if (typeof o.applicationUrl !== "string" || !/^https?:\/\/[^\s/]+/i.test(o.applicationUrl) || o.applicationUrl.length > 2000)
|
|
75
|
+
throw bad("applicationUrl must be an http(s) URL");
|
|
76
|
+
const job = (o.job && typeof o.job === "object" ? o.job : {});
|
|
77
|
+
if (typeof job.title !== "string" || !job.title.trim())
|
|
78
|
+
throw bad("job.title is required");
|
|
79
|
+
if (typeof o.workspace !== "string" || !isHomeRelative(o.workspace))
|
|
80
|
+
throw bad('workspace must be a folder written "~/…"');
|
|
81
|
+
const sources = [];
|
|
82
|
+
for (const s of Array.isArray(o.sources) ? o.sources.slice(0, 4) : []) {
|
|
83
|
+
const r = (s && typeof s === "object" ? s : {});
|
|
84
|
+
if ((r.role !== "profile" && r.role !== "answers") || typeof r.path !== "string" || !isWorkspaceRelative(r.path) || r.path.split("/")[0] === "applications")
|
|
85
|
+
throw bad("each source needs a role (profile, answers) and a path inside the workspace");
|
|
86
|
+
sources.push({ role: r.role, path: r.path });
|
|
87
|
+
}
|
|
88
|
+
const artifacts = [];
|
|
89
|
+
for (const a of Array.isArray(o.artifacts) ? o.artifacts.slice(0, 2) : []) {
|
|
90
|
+
const r = (a && typeof a === "object" ? a : {});
|
|
91
|
+
if ((r.kind !== "resume" && r.kind !== "cover_letter") || typeof r.path !== "string" || !isHomeRelative(r.path) || typeof r.sha256 !== "string" || !HASH.test(r.sha256))
|
|
92
|
+
throw bad("each artifact needs a kind, a ~/ path and a sha256");
|
|
93
|
+
if (artifacts.some((x) => x.kind === r.kind))
|
|
94
|
+
throw bad(`artifact ${r.kind} is listed twice`);
|
|
95
|
+
artifacts.push({ kind: r.kind, path: r.path, sha256: r.sha256 });
|
|
96
|
+
}
|
|
97
|
+
const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
|
|
98
|
+
if (!LOCAL_APPLY_MODES.includes(p.mode))
|
|
99
|
+
throw bad("policy.mode must be fill_and_review or auto_submit");
|
|
100
|
+
const gate = (p.submitGate && typeof p.submitGate === "object" ? p.submitGate : null);
|
|
101
|
+
const gateId = gate && typeof gate.gateId === "string" && SAFE_ID.test(gate.gateId) ? gate.gateId : null;
|
|
102
|
+
// auto_submit without the gate PAGS evaluated is not a mode this runner will run.
|
|
103
|
+
if (p.mode === "auto_submit" && !gateId)
|
|
104
|
+
throw bad("auto_submit requires policy.submitGate");
|
|
105
|
+
const allowDomains = strList(p.allowDomains);
|
|
106
|
+
if (!allowDomains.length)
|
|
107
|
+
throw bad("policy.allowDomains must name the application's site");
|
|
108
|
+
const l = (o.limits && typeof o.limits === "object" ? o.limits : {});
|
|
109
|
+
const limits = { maxMinutes: posInt(l.maxMinutes, 60) ?? 0, maxPages: posInt(l.maxPages, 200) ?? 0, maxActions: posInt(l.maxActions, 1000) ?? 0 };
|
|
110
|
+
if (Object.values(limits).some((v) => !v))
|
|
111
|
+
throw bad("limits are out of range");
|
|
112
|
+
const opt = (v) => (typeof v === "string" && v.trim() ? v.trim().slice(0, 300) : undefined);
|
|
113
|
+
return {
|
|
114
|
+
type: LOCAL_APPLY_TASK_TYPE,
|
|
115
|
+
runId: o.runId,
|
|
116
|
+
requestId: o.requestId,
|
|
117
|
+
instanceId: o.instanceId,
|
|
118
|
+
applicationId: o.applicationId,
|
|
119
|
+
engine: o.engine,
|
|
120
|
+
authMode: o.authMode,
|
|
121
|
+
browserProfile: o.browserProfile,
|
|
122
|
+
applicationUrl: o.applicationUrl,
|
|
123
|
+
job: { title: job.title.trim().slice(0, 300), company: opt(job.company), location: opt(job.location) },
|
|
124
|
+
workspace: o.workspace,
|
|
125
|
+
sources,
|
|
126
|
+
artifacts,
|
|
127
|
+
policy: { mode: p.mode, allowDomains, ...(p.mode === "auto_submit" && gateId ? { submitGate: { gateId } } : {}) },
|
|
128
|
+
limits,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* An approved artifact on the real disk: inside the workspace's `applications/`, and still the
|
|
133
|
+
* file #956 hashed. Anything else is not uploaded.
|
|
134
|
+
*/
|
|
135
|
+
export function verifyArtifact(workspace, home, a) {
|
|
136
|
+
const label = a.kind.replace("_", " ");
|
|
137
|
+
try {
|
|
138
|
+
const real = realpathSync(resolve(realpathSync(home), a.path.slice(2)));
|
|
139
|
+
if (!real.startsWith(join(workspace, "applications") + sep))
|
|
140
|
+
return { error: `The approved ${label} is not inside the workspace's applications/ folder.` };
|
|
141
|
+
const buf = readFileSync(real);
|
|
142
|
+
if (sha256(buf) !== a.sha256)
|
|
143
|
+
return { error: `The approved ${label} at ${a.path} has changed since it was generated; it will not be uploaded.` };
|
|
144
|
+
return { path: real, text: buf.toString("utf8") };
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { error: `The approved ${label} at ${a.path} could not be read.` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export class LocalApplyRuntime {
|
|
151
|
+
deps;
|
|
152
|
+
runs = new Map();
|
|
153
|
+
root;
|
|
154
|
+
now;
|
|
155
|
+
spawn;
|
|
156
|
+
retentionMs;
|
|
157
|
+
constructor(deps) {
|
|
158
|
+
this.deps = deps;
|
|
159
|
+
this.root = join(deps.dataDir, "local-apply");
|
|
160
|
+
this.now = deps.now ?? Date.now;
|
|
161
|
+
this.spawn = deps.spawn ?? nodeSpawn;
|
|
162
|
+
this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
|
|
163
|
+
}
|
|
164
|
+
start(raw) {
|
|
165
|
+
const envelope = parseApplyEnvelope(raw);
|
|
166
|
+
this.sweep();
|
|
167
|
+
const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
|
|
168
|
+
if (same)
|
|
169
|
+
return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
|
|
170
|
+
if (this.runs.has(envelope.runId))
|
|
171
|
+
throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
|
|
172
|
+
const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
|
|
173
|
+
if (active >= MAX_ACTIVE)
|
|
174
|
+
throw new RunnerInputError("This machine is already filling an application for this agent; one at a time.", 409);
|
|
175
|
+
const selfUrl = this.deps.selfUrl();
|
|
176
|
+
if (!selfUrl)
|
|
177
|
+
throw new RunnerInputError("The runner is still starting; try again in a moment.", 409);
|
|
178
|
+
const dir = join(this.root, envelope.instanceId.replace(/[^A-Za-z0-9_-]/g, "_"), envelope.runId);
|
|
179
|
+
mkdirSync(join(dir, "scratch"), { recursive: true });
|
|
180
|
+
const run = {
|
|
181
|
+
envelope,
|
|
182
|
+
dir,
|
|
183
|
+
token: randomBytes(24).toString("hex"),
|
|
184
|
+
state: "running",
|
|
185
|
+
events: [],
|
|
186
|
+
seq: 0,
|
|
187
|
+
allow: new Set(envelope.policy.allowDomains),
|
|
188
|
+
grounding: [],
|
|
189
|
+
secrets: secretEnvValues(process.env),
|
|
190
|
+
activeSince: this.now(),
|
|
191
|
+
activeMs: 0,
|
|
192
|
+
waiters: [],
|
|
193
|
+
output: [],
|
|
194
|
+
engineAuth: "unknown",
|
|
195
|
+
cancelled: false,
|
|
196
|
+
timedOut: false,
|
|
197
|
+
};
|
|
198
|
+
this.runs.set(envelope.runId, run);
|
|
199
|
+
void this.launch(run, selfUrl).catch((err) => {
|
|
200
|
+
if (err instanceof Blocked)
|
|
201
|
+
this.end(run, { outcome: "blocked", blockReason: err.reason, questions: err.questions });
|
|
202
|
+
else
|
|
203
|
+
this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
204
|
+
});
|
|
205
|
+
return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
|
|
206
|
+
}
|
|
207
|
+
async launch(run, selfUrl) {
|
|
208
|
+
const e = run.envelope;
|
|
209
|
+
const home = this.deps.homeDir ?? homedir();
|
|
210
|
+
let workspace;
|
|
211
|
+
try {
|
|
212
|
+
workspace = resolveWorkspacePath(e.workspace, home);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw new Blocked("source_unavailable", [`The workspace ${e.workspace} cannot be used: ${err instanceof Error ? err.message : String(err)}`]);
|
|
216
|
+
}
|
|
217
|
+
const blocks = [];
|
|
218
|
+
const missing = [];
|
|
219
|
+
if (!e.sources.some((s) => s.role === "profile"))
|
|
220
|
+
missing.push("No profile source is configured. Choose one in the Application Runner settings.");
|
|
221
|
+
for (const s of e.sources) {
|
|
222
|
+
const shown = `${e.workspace}/${s.path}`;
|
|
223
|
+
try {
|
|
224
|
+
const buf = readFileSync(resolveSource(workspace, s.path));
|
|
225
|
+
if (!buf.length || buf.length > SOURCE_BYTES)
|
|
226
|
+
throw new Error("empty or too large");
|
|
227
|
+
blocks.push({ label: s.role, text: buf.toString("utf8") });
|
|
228
|
+
this.emit(run, { type: "source.read", detail: { role: s.role, path: shown, sha256: sha256(buf), bytes: buf.length } });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
missing.push(`Your ${s.role} at ${shown} could not be read. Add it, or choose another file in the Application Runner settings.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (missing.length)
|
|
235
|
+
throw new Blocked("source_unavailable", missing);
|
|
236
|
+
for (const a of e.artifacts) {
|
|
237
|
+
const v = verifyArtifact(workspace, home, a);
|
|
238
|
+
if ("error" in v)
|
|
239
|
+
throw new Blocked("artifact_changed", [`${v.error} Re-run tailoring for this application.`]);
|
|
240
|
+
blocks.push({ label: a.kind, text: v.text });
|
|
241
|
+
}
|
|
242
|
+
run.grounding = blocks.map((b) => b.text);
|
|
243
|
+
const bridgeScript = this.deps.bridgeScript ?? fileURLToPath(new URL("../local-browser/bridge-stdio.js", import.meta.url));
|
|
244
|
+
const spec = buildApplyEngineSpec(e, applyPrompt(e, blocks), { command: process.execPath, args: [bridgeScript], env: { [BRIDGE_ENV.url]: selfUrl, [BRIDGE_ENV.runId]: e.runId, [BRIDGE_ENV.token]: run.token } }, join(run.dir, "mcp.json"));
|
|
245
|
+
run.secrets = secretEnvValues({ ...process.env, ...spec.env });
|
|
246
|
+
run.engineAuth = observedApplyAuth(e.engine, spec.env);
|
|
247
|
+
this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth, mode: e.policy.mode } });
|
|
248
|
+
if (run.engineAuth === "api-key")
|
|
249
|
+
throw new Blocked("api_key_refused", ["A provider API key would reach the CLI. Applications run on this machine's sign-in only — remove the key from the runner's environment."]);
|
|
250
|
+
run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
|
|
251
|
+
const artifactsByKind = new Map(e.artifacts.map((a) => [a.kind, a]));
|
|
252
|
+
run.bridge = new ApplyBridge(run.browser.tools, {
|
|
253
|
+
emit: (ev) => this.emit(run, ev),
|
|
254
|
+
pause: (p) => this.pause(run, p),
|
|
255
|
+
isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
|
|
256
|
+
grounding: () => run.grounding.join("\n"),
|
|
257
|
+
artifactPath: (kind) => {
|
|
258
|
+
const a = artifactsByKind.get(kind);
|
|
259
|
+
if (!a)
|
|
260
|
+
return { error: `No approved ${kind.replace("_", " ")} for this application.` };
|
|
261
|
+
const v = verifyArtifact(workspace, home, a);
|
|
262
|
+
return "error" in v ? v : { path: v.path, sha256: a.sha256 };
|
|
263
|
+
},
|
|
264
|
+
overTime: () => this.activeMs(run) >= e.limits.maxMinutes * 60_000,
|
|
265
|
+
now: this.now,
|
|
266
|
+
limits: e.limits,
|
|
267
|
+
mode: e.policy.mode,
|
|
268
|
+
gateId: e.policy.submitGate?.gateId,
|
|
269
|
+
});
|
|
270
|
+
if (spec.mcpConfig)
|
|
271
|
+
writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
|
|
272
|
+
const child = this.spawn(spec.command, spec.args, { cwd: join(run.dir, "scratch"), env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
273
|
+
run.child = child;
|
|
274
|
+
this.emit(run, { type: "engine.started", detail: { engine: e.engine, mode: e.policy.mode } });
|
|
275
|
+
let buf = "";
|
|
276
|
+
const keep = (chunk) => {
|
|
277
|
+
buf += chunk.toString();
|
|
278
|
+
const lines = buf.split("\n");
|
|
279
|
+
buf = lines.pop() ?? "";
|
|
280
|
+
for (const line of lines)
|
|
281
|
+
if (line.trim())
|
|
282
|
+
run.output.push(line.slice(0, 20_000));
|
|
283
|
+
if (run.output.length > OUTPUT_LINES)
|
|
284
|
+
run.output.splice(0, run.output.length - OUTPUT_LINES);
|
|
285
|
+
};
|
|
286
|
+
child.stdout?.on("data", keep);
|
|
287
|
+
child.stderr?.on("data", keep);
|
|
288
|
+
child.on("error", (err) => {
|
|
289
|
+
this.end(run, { outcome: "failed", error: err.code === "ENOENT" ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
|
|
290
|
+
});
|
|
291
|
+
child.on("close", (code) => {
|
|
292
|
+
if (buf.trim())
|
|
293
|
+
run.output.push(buf);
|
|
294
|
+
this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
|
|
295
|
+
this.end(run, this.outcomeOf(run, code ?? -1));
|
|
296
|
+
});
|
|
297
|
+
run.timer = setInterval(() => {
|
|
298
|
+
if (run.state !== "ended" && this.activeMs(run) >= e.limits.maxMinutes * 60_000 + 60_000) {
|
|
299
|
+
run.timedOut = true;
|
|
300
|
+
this.kill(run);
|
|
301
|
+
}
|
|
302
|
+
}, 5_000);
|
|
303
|
+
run.timer.unref?.();
|
|
304
|
+
}
|
|
305
|
+
/** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
|
|
306
|
+
outcomeOf(run, code) {
|
|
307
|
+
const e = run.envelope;
|
|
308
|
+
const b = run.bridge;
|
|
309
|
+
const summary = b?.summary ?? finalText(e.engine, run.output);
|
|
310
|
+
if (run.cancelled)
|
|
311
|
+
return { outcome: "failed", summary, error: "Cancelled by the owner" };
|
|
312
|
+
if (b?.submitted)
|
|
313
|
+
return { outcome: "submitted", summary, submitted: b.submitted };
|
|
314
|
+
if (b?.submitAttempted)
|
|
315
|
+
return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
|
|
316
|
+
if (b?.reviewReady)
|
|
317
|
+
return { outcome: "awaiting_review", summary };
|
|
318
|
+
if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
|
|
319
|
+
return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
|
|
320
|
+
if (b?.blocked)
|
|
321
|
+
return { outcome: "blocked", summary, blockReason: b.blocked.reason, questions: b.blocked.questions };
|
|
322
|
+
if (run.timedOut)
|
|
323
|
+
return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`] };
|
|
324
|
+
const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
|
|
325
|
+
return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`] };
|
|
326
|
+
}
|
|
327
|
+
activeMs(run) {
|
|
328
|
+
return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
|
|
329
|
+
}
|
|
330
|
+
emit(run, ev) {
|
|
331
|
+
if (run.events.length >= MAX_EVENTS)
|
|
332
|
+
return;
|
|
333
|
+
run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
334
|
+
}
|
|
335
|
+
pause(run, p) {
|
|
336
|
+
if (run.state === "ended" || run.cancelled)
|
|
337
|
+
return Promise.resolve("stopped");
|
|
338
|
+
if (run.state === "running") {
|
|
339
|
+
run.activeMs += this.now() - run.activeSince;
|
|
340
|
+
run.state = "paused";
|
|
341
|
+
run.pause = p;
|
|
342
|
+
this.emit(run, { type: "run.paused", pauseReason: p.reason, ...(p.url ? { url: p.url } : {}), ...(p.domain ? { domain: p.domain } : {}) });
|
|
343
|
+
}
|
|
344
|
+
return new Promise((done) => run.waiters.push(done));
|
|
345
|
+
}
|
|
346
|
+
release(run, outcome) {
|
|
347
|
+
if (run.state === "paused") {
|
|
348
|
+
run.state = "running";
|
|
349
|
+
run.pause = undefined;
|
|
350
|
+
run.activeSince = this.now();
|
|
351
|
+
if (outcome === "resumed")
|
|
352
|
+
this.emit(run, { type: "run.resumed" });
|
|
353
|
+
}
|
|
354
|
+
for (const w of run.waiters.splice(0))
|
|
355
|
+
w(outcome);
|
|
356
|
+
}
|
|
357
|
+
/** The owner acted: add their answers to what values may be grounded in, admit any site they allowed, release. */
|
|
358
|
+
resume(raw) {
|
|
359
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
360
|
+
const run = this.get(String(o.runId ?? ""));
|
|
361
|
+
if (run.state === "ended")
|
|
362
|
+
throw new RunnerInputError("The run has ended", 409);
|
|
363
|
+
for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
|
|
364
|
+
const r = (a && typeof a === "object" ? a : {});
|
|
365
|
+
if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
|
|
366
|
+
run.grounding.push(`Q: ${r.question.slice(0, LOCAL_APPLY_CAPS.questionChars)}\nA: ${r.answer.trim().slice(0, LOCAL_APPLY_CAPS.answerChars)}`);
|
|
367
|
+
}
|
|
368
|
+
for (const d of strList(o.allowDomains))
|
|
369
|
+
run.allow.add(d);
|
|
370
|
+
this.release(run, "resumed");
|
|
371
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
372
|
+
}
|
|
373
|
+
cancel(raw) {
|
|
374
|
+
const runId = String(raw?.runId ?? "");
|
|
375
|
+
const run = this.get(runId);
|
|
376
|
+
if (run.state !== "ended") {
|
|
377
|
+
run.cancelled = true;
|
|
378
|
+
this.release(run, "stopped");
|
|
379
|
+
if (run.child && run.child.exitCode === null)
|
|
380
|
+
this.kill(run);
|
|
381
|
+
else
|
|
382
|
+
this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
|
|
383
|
+
}
|
|
384
|
+
return { runId, state: run.state };
|
|
385
|
+
}
|
|
386
|
+
status(raw) {
|
|
387
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
388
|
+
const run = this.get(String(o.runId ?? ""));
|
|
389
|
+
const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
|
|
390
|
+
// A page of at most 500 events; `lastSeq` is the cursor for the next page, so nothing is skipped.
|
|
391
|
+
const pending = run.events.filter((e) => e.seq > after);
|
|
392
|
+
const page = pending.length > 500 ? pending.slice(0, 500) : pending;
|
|
393
|
+
return {
|
|
394
|
+
runId: run.envelope.runId,
|
|
395
|
+
state: run.state,
|
|
396
|
+
...(run.pause ? { pause: run.pause } : {}),
|
|
397
|
+
events: page,
|
|
398
|
+
lastSeq: page.length < pending.length ? page[page.length - 1].seq : run.seq,
|
|
399
|
+
...(run.result ? { result: run.result } : {}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** Does this token belong to this run? The bridge forwarder's only credential. */
|
|
403
|
+
authorizeBridge(runId, token) {
|
|
404
|
+
const run = this.runs.get(runId);
|
|
405
|
+
return !!run && run.state !== "ended" && token.length > 0 && token === run.token;
|
|
406
|
+
}
|
|
407
|
+
async bridge(raw) {
|
|
408
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
409
|
+
const run = this.get(String(o.runId ?? ""));
|
|
410
|
+
if (!run.bridge || run.state === "ended")
|
|
411
|
+
throw new RunnerInputError("The run is not active", 409);
|
|
412
|
+
if (o.op === "list")
|
|
413
|
+
return { tools: await run.bridge.listTools() };
|
|
414
|
+
// A call that arrives while the run waits on the owner (the CLI gave up waiting on its own
|
|
415
|
+
// call) does not go ahead of them: nothing reaches the page until the owner resumes.
|
|
416
|
+
if (o.op === "call" && run.state === "paused")
|
|
417
|
+
return { content: [{ type: "text", text: `The run is paused for the owner (${run.pause?.reason.replace(/_/g, " ") ?? "waiting"}). Nothing can be done until they resume it. Stop now.` }], isError: true };
|
|
418
|
+
if (o.op === "call" && typeof o.name === "string")
|
|
419
|
+
return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
|
|
420
|
+
throw new RunnerInputError("op must be list or call");
|
|
421
|
+
}
|
|
422
|
+
get(runId) {
|
|
423
|
+
const run = this.runs.get(runId);
|
|
424
|
+
if (!run)
|
|
425
|
+
throw new RunnerInputError(`No application run ${runId} on this runner (it may have restarted)`, 404);
|
|
426
|
+
return run;
|
|
427
|
+
}
|
|
428
|
+
kill(run) {
|
|
429
|
+
const child = run.child;
|
|
430
|
+
if (!child || child.exitCode !== null)
|
|
431
|
+
return;
|
|
432
|
+
child.kill("SIGTERM");
|
|
433
|
+
setTimeout(() => {
|
|
434
|
+
if (child.exitCode === null)
|
|
435
|
+
child.kill("SIGKILL");
|
|
436
|
+
}, 5_000).unref?.();
|
|
437
|
+
}
|
|
438
|
+
end(run, r) {
|
|
439
|
+
if (run.state === "ended")
|
|
440
|
+
return;
|
|
441
|
+
if (run.state === "paused")
|
|
442
|
+
this.release(run, "stopped");
|
|
443
|
+
if (run.timer)
|
|
444
|
+
clearInterval(run.timer);
|
|
445
|
+
run.state = "ended";
|
|
446
|
+
run.endedAt = this.now();
|
|
447
|
+
const b = run.bridge;
|
|
448
|
+
run.result = {
|
|
449
|
+
runId: run.envelope.runId,
|
|
450
|
+
outcome: r.outcome,
|
|
451
|
+
mode: run.envelope.policy.mode,
|
|
452
|
+
traceId: run.envelope.runId,
|
|
453
|
+
engineAuth: r.engineAuth ?? run.engineAuth,
|
|
454
|
+
filled: b?.filled ?? 0,
|
|
455
|
+
uploaded: [...(b?.uploaded ?? [])],
|
|
456
|
+
submitAttempted: b?.submitAttempted ?? false,
|
|
457
|
+
summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
|
|
458
|
+
...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
|
|
459
|
+
...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
|
|
460
|
+
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
461
|
+
};
|
|
462
|
+
if (run.child && run.child.exitCode === null)
|
|
463
|
+
this.kill(run);
|
|
464
|
+
void run.browser?.stop().catch(() => undefined);
|
|
465
|
+
}
|
|
466
|
+
/** Drop ended runs past retention, with their folders (scratch, MCP config, isolated profile). */
|
|
467
|
+
sweep() {
|
|
468
|
+
const cutoff = this.now() - this.retentionMs;
|
|
469
|
+
for (const [id, run] of this.runs) {
|
|
470
|
+
if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
|
|
471
|
+
rmSync(run.dir, { recursive: true, force: true });
|
|
472
|
+
this.runs.delete(id);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!existsSync(this.root))
|
|
476
|
+
return;
|
|
477
|
+
for (const instance of readdirSync(this.root)) {
|
|
478
|
+
const instDir = join(this.root, instance);
|
|
479
|
+
for (const runId of readdirSync(instDir)) {
|
|
480
|
+
if (this.runs.has(runId))
|
|
481
|
+
continue;
|
|
482
|
+
const dir = join(instDir, runId);
|
|
483
|
+
if (statSync(dir).mtimeMs < cutoff)
|
|
484
|
+
rmSync(dir, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
closeAll() {
|
|
489
|
+
for (const run of this.runs.values()) {
|
|
490
|
+
if (run.state !== "ended") {
|
|
491
|
+
run.cancelled = true;
|
|
492
|
+
this.release(run, "stopped");
|
|
493
|
+
this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -89,7 +89,7 @@ export function parseArtifactEnvelope(raw) {
|
|
|
89
89
|
}
|
|
90
90
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
91
91
|
/** A source file resolved on the real disk, strictly inside the workspace. */
|
|
92
|
-
function resolveSource(workspace, rel) {
|
|
92
|
+
export function resolveSource(workspace, rel) {
|
|
93
93
|
const real = realpathSync(resolve(workspace, rel));
|
|
94
94
|
if (!real.startsWith(workspace + sep))
|
|
95
95
|
throw new Error("outside the workspace");
|
|
@@ -101,7 +101,7 @@ export function buildEngineSpec(input) {
|
|
|
101
101
|
"-c",
|
|
102
102
|
`${key}.default_tools_approval_mode="approve"`,
|
|
103
103
|
"-c",
|
|
104
|
-
`${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
|
|
104
|
+
`${key}.enabled_tools=[${(input.toolNames ?? BRIDGE_TOOL_NAMES).map(toml).join(", ")}]`,
|
|
105
105
|
// Two spellings across Codex versions; an unknown key is ignored, so both are set.
|
|
106
106
|
"-c",
|
|
107
107
|
'web_search="disabled"',
|
|
@@ -16,6 +16,8 @@ import { LocalBrowserRuntime } from "./local-browser/runtime.js";
|
|
|
16
16
|
import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
|
|
17
17
|
import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
|
|
18
18
|
import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
|
|
19
|
+
import { LocalApplyRuntime } from "./local-apply/runtime.js";
|
|
20
|
+
import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
|
|
19
21
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
20
22
|
/** True for a plain object. */
|
|
21
23
|
function isRecord(value) {
|
|
@@ -81,6 +83,8 @@ export class LocalRunner {
|
|
|
81
83
|
localBrowser;
|
|
82
84
|
/** Local artifact generation (#956): the Application Tailor's résumé + cover letter, from the owner's own files. */
|
|
83
85
|
localArtifact;
|
|
86
|
+
/** Local application execution (#957): a signed-in CLI filling one application through the apply bridge. */
|
|
87
|
+
localApply;
|
|
84
88
|
/** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
|
|
85
89
|
selfUrl = null;
|
|
86
90
|
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
@@ -90,20 +94,18 @@ export class LocalRunner {
|
|
|
90
94
|
mkdirSync(config.dataDir, { recursive: true });
|
|
91
95
|
this.store = new RunnerStore(config.dataDir);
|
|
92
96
|
this.coding = new CodingRuntime(join(config.dataDir, "repos"));
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return { tools: mcp, stop: () => mcp.stop() };
|
|
104
|
-
},
|
|
105
|
-
});
|
|
97
|
+
// `default` is the runner's own signed-in browser, shared and never closed by a run;
|
|
98
|
+
// `isolated` is a fresh profile inside the run's folder, removed with it.
|
|
99
|
+
const browserFor = async (profile, runDir) => {
|
|
100
|
+
if (profile === "default")
|
|
101
|
+
return { tools: await this.getMcp(), stop: async () => undefined };
|
|
102
|
+
const mcp = new McpRuntime();
|
|
103
|
+
await mcp.start({ userDataDir: join(runDir, "profile"), headless: config.headless });
|
|
104
|
+
return { tools: mcp, stop: () => mcp.stop() };
|
|
105
|
+
};
|
|
106
|
+
this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
|
|
106
107
|
this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
|
|
108
|
+
this.localApply = new LocalApplyRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
|
|
107
109
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
108
110
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
109
111
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -118,7 +120,7 @@ export class LocalRunner {
|
|
|
118
120
|
runtimePlane: "pags",
|
|
119
121
|
runnerRole: "tool-executor",
|
|
120
122
|
capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
|
|
121
|
-
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, LOCAL_ARTIFACT_TASK_TYPE, ...CodingRuntime.taskTypes()],
|
|
123
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, LOCAL_ARTIFACT_TASK_TYPE, LOCAL_APPLY_TASK_TYPE, ...CodingRuntime.taskTypes()],
|
|
122
124
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
123
125
|
};
|
|
124
126
|
}
|
|
@@ -256,6 +258,7 @@ export class LocalRunner {
|
|
|
256
258
|
async close() {
|
|
257
259
|
this.localBrowser.closeAll();
|
|
258
260
|
this.localArtifact.closeAll();
|
|
261
|
+
this.localApply.closeAll();
|
|
259
262
|
try {
|
|
260
263
|
this.coding.closeAll();
|
|
261
264
|
}
|
|
@@ -12,7 +12,11 @@ export function createRunnerServer(runner) {
|
|
|
12
12
|
if (!req.headers.origin && req.method === "POST" && (req.url || "").split("?")[0] === "/local-browser/bridge") {
|
|
13
13
|
const body = await readJson(req);
|
|
14
14
|
const token = String(req.headers["x-pags-bridge-token"] || "");
|
|
15
|
-
|
|
15
|
+
const runId = String(body.runId ?? "");
|
|
16
|
+
// The same forwarder serves application runs (#957); each runtime knows only its own tokens.
|
|
17
|
+
if (runner.localApply.authorizeBridge(runId, token))
|
|
18
|
+
return json(res, 200, await runner.localApply.bridge(body));
|
|
19
|
+
if (!runner.localBrowser.authorizeBridge(runId, token))
|
|
16
20
|
return json(res, 401, { error: "Unauthorized" });
|
|
17
21
|
return json(res, 200, await runner.localBrowser.bridge(body));
|
|
18
22
|
}
|
|
@@ -187,6 +191,19 @@ async function route(runner, req, res) {
|
|
|
187
191
|
if (req.method === "POST" && path === "/local-artifact/cancel") {
|
|
188
192
|
return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
|
|
189
193
|
}
|
|
194
|
+
// Local application execution (#957) — run, status, resume, cancel; PULL like research.
|
|
195
|
+
if (req.method === "POST" && path === "/local-apply/run") {
|
|
196
|
+
return json(res, 202, runner.localApply.start(await readJson(req)));
|
|
197
|
+
}
|
|
198
|
+
if (req.method === "POST" && path === "/local-apply/status") {
|
|
199
|
+
return json(res, 200, runner.localApply.status(await readJson(req)));
|
|
200
|
+
}
|
|
201
|
+
if (req.method === "POST" && path === "/local-apply/resume") {
|
|
202
|
+
return json(res, 200, runner.localApply.resume(await readJson(req)));
|
|
203
|
+
}
|
|
204
|
+
if (req.method === "POST" && path === "/local-apply/cancel") {
|
|
205
|
+
return json(res, 200, runner.localApply.cancel(await readJson(req)));
|
|
206
|
+
}
|
|
190
207
|
if (req.method === "POST" && path === "/coding/start") {
|
|
191
208
|
const b = await readJson(req);
|
|
192
209
|
return json(res, 200, runner.coding.start(b));
|