@proagentstore/cli 0.4.82 → 0.4.84
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 +119 -3
- package/dist/browser-runner/local-apply/contract.js +46 -1
- package/dist/browser-runner/local-apply/engine.js +2 -0
- package/dist/browser-runner/local-apply/runtime.js +142 -4
- package/dist/browser-runner/server.js +3 -0
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@ import { normalize } from "../local-artifact/engine.js";
|
|
|
29
29
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
30
30
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
|
|
31
31
|
const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
|
|
32
|
-
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review"];
|
|
32
|
+
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review", "supervisor_checkpoint"];
|
|
33
33
|
const REFUSED = new Set([
|
|
34
34
|
"browser_fill_form",
|
|
35
35
|
"browser_file_upload",
|
|
@@ -70,6 +70,9 @@ const INSPECT_PAGE = `() => {
|
|
|
70
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
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
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
|
+
unavailable: /(?:this |the )?(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/.test(text)
|
|
74
|
+
? (/expired/.test(text) ? "expired" : "unavailable")
|
|
75
|
+
: null,
|
|
73
76
|
};
|
|
74
77
|
}`;
|
|
75
78
|
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
|
|
@@ -102,14 +105,19 @@ function withQuote(schema, required) {
|
|
|
102
105
|
export class ApplyBridge {
|
|
103
106
|
browser;
|
|
104
107
|
host;
|
|
108
|
+
/** Read by the runtime for the #975 diagnostic: 0 calls is the finding, not an internal detail. */
|
|
105
109
|
actions = 0;
|
|
106
110
|
pages = 0;
|
|
107
111
|
lastSnapshot = "";
|
|
108
112
|
admitted = new Set();
|
|
113
|
+
/** Fill and submit are disabled until the cloud supervisor releases the current page checkpoint. */
|
|
114
|
+
supervisorApproved = false;
|
|
109
115
|
filled = 0;
|
|
110
116
|
uploaded = new Set();
|
|
111
117
|
reviewReady = false;
|
|
112
118
|
summary = null;
|
|
119
|
+
/** Present only after the CLI explicitly reports a notice that the runner independently saw. */
|
|
120
|
+
unavailable = null;
|
|
113
121
|
submitAttempted = false;
|
|
114
122
|
submitted = null;
|
|
115
123
|
/** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
|
|
@@ -119,7 +127,7 @@ export class ApplyBridge {
|
|
|
119
127
|
this.host = host;
|
|
120
128
|
}
|
|
121
129
|
get done() {
|
|
122
|
-
return this.reviewReady || this.submitAttempted;
|
|
130
|
+
return this.reviewReady || this.submitAttempted || this.unavailable !== null;
|
|
123
131
|
}
|
|
124
132
|
async listTools() {
|
|
125
133
|
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");
|
|
@@ -149,6 +157,16 @@ export class ApplyBridge {
|
|
|
149
157
|
description: "The form is filled as far as the sources allow. Stop here; the owner reviews it. Call once, last.",
|
|
150
158
|
inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
|
151
159
|
},
|
|
160
|
+
{
|
|
161
|
+
name: "supervisor_checkpoint",
|
|
162
|
+
description: "Pause safely for the cloud supervisor. Use the exact checkpointId supplied in your task; do not continue until this returns continue. request_review and stop are terminally handled by the runner.",
|
|
163
|
+
inputSchema: { type: "object", properties: { checkpointId: { type: "string", minLength: 1, maxLength: 300 }, phase: { type: "string", enum: ["initial", "post_navigation", "before_submit", "uncertain"] } }, required: ["checkpointId", "phase"], additionalProperties: false },
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "report_job_unavailable",
|
|
167
|
+
description: "End the run when a fresh browser snapshot shows that this job is expired, closed or unavailable. The runner independently verifies the page notice; it refuses unsupported reports.",
|
|
168
|
+
inputSchema: { type: "object", properties: { reason: { type: "string", enum: ["expired", "unavailable"] } }, required: ["reason"] },
|
|
169
|
+
},
|
|
152
170
|
];
|
|
153
171
|
}
|
|
154
172
|
async callTool(name, args = {}) {
|
|
@@ -158,6 +176,10 @@ export class ApplyBridge {
|
|
|
158
176
|
return this.review(typeof args.summary === "string" ? args.summary : "");
|
|
159
177
|
if (name === "request_answer")
|
|
160
178
|
return this.requestAnswer(args);
|
|
179
|
+
if (name === "supervisor_checkpoint")
|
|
180
|
+
return this.supervisorCheckpoint(args);
|
|
181
|
+
if (name === "report_job_unavailable")
|
|
182
|
+
return this.reportUnavailable(args);
|
|
161
183
|
if (REFUSED.has(name))
|
|
162
184
|
return this.refuse(name, "read", `${name} is not available to an application run.`);
|
|
163
185
|
if (!READ_TOOLS.has(name) && !FILL_TOOLS.has(name) && name !== "browser_click" && name !== "browser_press_key" && name !== "upload_artifact")
|
|
@@ -200,7 +222,8 @@ export class ApplyBridge {
|
|
|
200
222
|
const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
|
|
201
223
|
if (!v || typeof v.url !== "string")
|
|
202
224
|
return null;
|
|
203
|
-
|
|
225
|
+
const unavailable = v.unavailable === "expired" || v.unavailable === "unavailable" ? v.unavailable : null;
|
|
226
|
+
return { url: v.url, title: String(v.title ?? ""), captcha: !!v.captcha, login: !!v.login, duplicate: !!v.duplicate, antiBot: !!v.antiBot, confirmed: !!v.confirmed, unavailable };
|
|
204
227
|
}
|
|
205
228
|
blockerOf(s) {
|
|
206
229
|
return s.captcha ? "captcha" : s.antiBot ? "anti_bot" : s.duplicate ? "duplicate_application" : s.login ? "login_required" : null;
|
|
@@ -213,12 +236,16 @@ export class ApplyBridge {
|
|
|
213
236
|
return false;
|
|
214
237
|
if (recheck && !(await recheck()))
|
|
215
238
|
return false;
|
|
239
|
+
// A person may have changed the page or its answers while handling the pause.
|
|
240
|
+
this.supervisorApproved = false;
|
|
216
241
|
this.blocked = null;
|
|
217
242
|
return true;
|
|
218
243
|
}
|
|
219
244
|
/** After a page change: count it, trace it, and stop for a person when the page needs one. */
|
|
220
245
|
async landed(toolText, known) {
|
|
221
246
|
const state = known ?? (await this.inspect());
|
|
247
|
+
// A navigation can put the CLI on a materially different form, even on the same origin.
|
|
248
|
+
this.supervisorApproved = false;
|
|
222
249
|
if (!state)
|
|
223
250
|
return text(toolText);
|
|
224
251
|
this.pages++;
|
|
@@ -316,13 +343,21 @@ export class ApplyBridge {
|
|
|
316
343
|
// A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
|
|
317
344
|
const fileInput = facts.tag === "input" && facts.type === "file";
|
|
318
345
|
if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
|
|
346
|
+
const checkpoint = this.requireSupervisor("browser_click", "fill");
|
|
347
|
+
if (checkpoint)
|
|
348
|
+
return checkpoint;
|
|
319
349
|
this.allow("browser_click", "fill");
|
|
320
350
|
const res = await this.browser.callTool("browser_click", forward);
|
|
351
|
+
if (!res.isError && !fileInput)
|
|
352
|
+
this.supervisorApproved = false;
|
|
321
353
|
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
322
354
|
}
|
|
323
355
|
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
356
|
}
|
|
325
357
|
async fill(tool, role, forward) {
|
|
358
|
+
const checkpoint = this.requireSupervisor(tool, "fill");
|
|
359
|
+
if (checkpoint)
|
|
360
|
+
return checkpoint;
|
|
326
361
|
const res = await this.browser.callTool(tool, forward);
|
|
327
362
|
if (res.isError)
|
|
328
363
|
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
@@ -368,6 +403,9 @@ export class ApplyBridge {
|
|
|
368
403
|
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
369
404
|
}
|
|
370
405
|
async upload(args) {
|
|
406
|
+
const checkpoint = this.requireSupervisor("upload_artifact", "fill");
|
|
407
|
+
if (checkpoint)
|
|
408
|
+
return checkpoint;
|
|
371
409
|
const kind = args.kind === "resume" || args.kind === "cover_letter" ? args.kind : null;
|
|
372
410
|
if (!kind)
|
|
373
411
|
return this.refuse("upload_artifact", "fill", "kind must be resume or cover_letter.");
|
|
@@ -401,6 +439,74 @@ export class ApplyBridge {
|
|
|
401
439
|
return text("The owner resumed without answering: leave that field empty and continue with the others.");
|
|
402
440
|
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
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The CLI may ask to stop at a named checkpoint, but it cannot smuggle a decision through this
|
|
444
|
+
* tool. The runner waits for a separately persisted, typed directive and terminal directives are
|
|
445
|
+
* resolved by the runtime before this call returns.
|
|
446
|
+
*/
|
|
447
|
+
async supervisorCheckpoint(args) {
|
|
448
|
+
const checkpointId = typeof args.checkpointId === "string" ? args.checkpointId.trim() : "";
|
|
449
|
+
if (!/^[A-Za-z0-9_.:-]{1,300}$/.test(checkpointId))
|
|
450
|
+
return text("supervisor_checkpoint needs a safe checkpointId (letters, numbers, dot, underscore, colon or hyphen).", true);
|
|
451
|
+
const phase = args.phase === "initial" || args.phase === "post_navigation" || args.phase === "before_submit" || args.phase === "uncertain" ? args.phase : null;
|
|
452
|
+
if (!phase)
|
|
453
|
+
return text("supervisor_checkpoint needs phase initial, post_navigation, before_submit or uncertain.", true);
|
|
454
|
+
const state = await this.inspect();
|
|
455
|
+
const blockers = new Set();
|
|
456
|
+
if (state?.captcha)
|
|
457
|
+
blockers.add("captcha");
|
|
458
|
+
if (state?.login)
|
|
459
|
+
blockers.add("login_required");
|
|
460
|
+
if (state?.antiBot)
|
|
461
|
+
blockers.add("anti_bot");
|
|
462
|
+
if (state?.duplicate)
|
|
463
|
+
blockers.add("duplicate_application");
|
|
464
|
+
if (this.blocked && ["missing_answer", "screening_ambiguity", "external_redirect", "duplicate_application", "anti_bot", "login_required", "captcha"].includes(this.blocked.reason))
|
|
465
|
+
blockers.add(this.blocked.reason);
|
|
466
|
+
const url = state?.url;
|
|
467
|
+
const domain = url ? hostOf(url) ?? undefined : undefined;
|
|
468
|
+
const directive = await this.host.supervisorCheckpoint({
|
|
469
|
+
schemaVersion: 1,
|
|
470
|
+
checkpointId,
|
|
471
|
+
facts: {
|
|
472
|
+
phase,
|
|
473
|
+
actions: this.actions,
|
|
474
|
+
filled: this.filled,
|
|
475
|
+
uploaded: this.uploaded.size,
|
|
476
|
+
blockers: [...blockers],
|
|
477
|
+
...(url ? { url } : {}),
|
|
478
|
+
...(domain ? { domain } : {}),
|
|
479
|
+
...(state?.title ? { title: state.title.slice(0, 300) } : {}),
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
if (directive === "continue") {
|
|
483
|
+
this.supervisorApproved = true;
|
|
484
|
+
return text("The persisted supervisor directive is continue. You may continue using only the application bridge tools.");
|
|
485
|
+
}
|
|
486
|
+
return text(`The persisted supervisor directive is ${directive}. The runner has ended this application run locally; do not take any further action.`, true);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* A terminal result needs the runner's own fresh observation, not the CLI's reading of a page.
|
|
490
|
+
* The snapshot requirement makes the notice visible to the CLI; the static page probe verifies
|
|
491
|
+
* its category and current URL before any evidence is retained.
|
|
492
|
+
*/
|
|
493
|
+
async reportUnavailable(args) {
|
|
494
|
+
const reason = args.reason === "expired" || args.reason === "unavailable" ? args.reason : null;
|
|
495
|
+
if (!reason)
|
|
496
|
+
return text("reason must be expired or unavailable.", true);
|
|
497
|
+
if (!this.lastSnapshot)
|
|
498
|
+
return text("Take a fresh browser_snapshot that shows the listing notice before reporting it unavailable.", true);
|
|
499
|
+
const state = await this.inspect();
|
|
500
|
+
if (!state?.unavailable)
|
|
501
|
+
return text("The runner could not verify an expired or unavailable listing notice on the current page. Continue only if the page is still actionable, or take a fresh snapshot.", true);
|
|
502
|
+
if (state.unavailable !== reason)
|
|
503
|
+
return text(`The runner verified this listing as ${state.unavailable}, not ${reason}. Report that reason or take a fresh snapshot.`, true);
|
|
504
|
+
const url = state.url;
|
|
505
|
+
const domain = hostOf(url) ?? "";
|
|
506
|
+
this.unavailable = { reason, url, observedAt: new Date(this.host.now()).toISOString(), source: "page_notice" };
|
|
507
|
+
this.host.emit({ type: "job.unavailable", url, domain, detail: { reason, source: "page_notice" } });
|
|
508
|
+
return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
|
|
509
|
+
}
|
|
404
510
|
review(summary) {
|
|
405
511
|
this.reviewReady = true;
|
|
406
512
|
this.summary = summary; // bounded where the result is built (runtime.ts `end`)
|
|
@@ -409,6 +515,9 @@ export class ApplyBridge {
|
|
|
409
515
|
}
|
|
410
516
|
/** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
|
|
411
517
|
async submit(forward) {
|
|
518
|
+
const checkpoint = this.requireSupervisor("browser_click", "submit");
|
|
519
|
+
if (checkpoint)
|
|
520
|
+
return checkpoint;
|
|
412
521
|
if (this.host.mode !== "auto_submit" || !this.host.gateId) {
|
|
413
522
|
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
|
|
414
523
|
this.review("Stopped at the final submit: this run fills and waits for review.");
|
|
@@ -439,4 +548,11 @@ export class ApplyBridge {
|
|
|
439
548
|
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
|
|
440
549
|
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
441
550
|
}
|
|
551
|
+
/** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
|
|
552
|
+
requireSupervisor(tool, actionClass) {
|
|
553
|
+
if (this.supervisorApproved)
|
|
554
|
+
return null;
|
|
555
|
+
this.host.emit({ type: "policy.decision", detail: { tool, class: actionClass, decision: "refused", reason: "supervisor_checkpoint" } });
|
|
556
|
+
return text(`A persisted supervisor_checkpoint must return continue before ${actionClass === "submit" ? "the final submit" : "any fill or upload"}. Take a browser_snapshot if needed, then call supervisor_checkpoint.`, true);
|
|
557
|
+
}
|
|
442
558
|
}
|
|
@@ -31,6 +31,8 @@ export const LOCAL_APPLY_RUN_PATH = "/local-apply/run";
|
|
|
31
31
|
export const LOCAL_APPLY_STATUS_PATH = "/local-apply/status";
|
|
32
32
|
export const LOCAL_APPLY_RESUME_PATH = "/local-apply/resume";
|
|
33
33
|
export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
|
|
34
|
+
/** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
|
|
35
|
+
export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
|
|
34
36
|
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
35
37
|
export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
|
|
36
38
|
export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
|
|
@@ -43,6 +45,7 @@ export const LOCAL_APPLY_PAUSE_REASONS = [
|
|
|
43
45
|
"external_redirect",
|
|
44
46
|
"duplicate_application",
|
|
45
47
|
"anti_bot",
|
|
48
|
+
"supervisor_checkpoint",
|
|
46
49
|
];
|
|
47
50
|
export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
48
51
|
...LOCAL_APPLY_PAUSE_REASONS,
|
|
@@ -52,7 +55,12 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
52
55
|
"api_key_refused",
|
|
53
56
|
"source_unavailable",
|
|
54
57
|
"artifact_changed",
|
|
58
|
+
"job_unavailable",
|
|
59
|
+
"bridge_unused",
|
|
55
60
|
];
|
|
61
|
+
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
|
|
62
|
+
export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
|
|
63
|
+
export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
|
|
56
64
|
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
57
65
|
"engine.auth_checked",
|
|
58
66
|
"engine.started",
|
|
@@ -67,16 +75,21 @@ export const LOCAL_APPLY_EVENT_TYPES = [
|
|
|
67
75
|
"submit.attempted",
|
|
68
76
|
"submit.confirmed",
|
|
69
77
|
"submit.unconfirmed",
|
|
78
|
+
"job.unavailable",
|
|
79
|
+
"supervisor.checkpoint",
|
|
80
|
+
"supervisor.directive",
|
|
70
81
|
"run.paused",
|
|
71
82
|
"run.resumed",
|
|
72
83
|
"note",
|
|
73
84
|
];
|
|
74
85
|
export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
86
|
+
export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
|
|
87
|
+
export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
|
|
75
88
|
export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
|
|
76
89
|
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
77
90
|
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
78
91
|
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"]);
|
|
92
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
|
|
80
93
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
81
94
|
export function parseLocalApplyEvent(raw) {
|
|
82
95
|
if (!raw || typeof raw !== "object")
|
|
@@ -152,13 +165,45 @@ export function parseLocalApplyResult(raw) {
|
|
|
152
165
|
out.submitted = { url, at, gateId };
|
|
153
166
|
out.submitAttempted = true;
|
|
154
167
|
}
|
|
168
|
+
// #975 — accepted ONLY as the closed vocabulary it declares. A runner that sends prose here gets
|
|
169
|
+
// it dropped rather than stored: the whole point of a structured diagnostic is that no new
|
|
170
|
+
// free-text channel opens between the machine and the owner's records.
|
|
171
|
+
const d = o.diagnostic && typeof o.diagnostic === "object" && !Array.isArray(o.diagnostic) ? o.diagnostic : null;
|
|
172
|
+
const cause = d ? oneOf(LOCAL_APPLY_DIAGNOSTIC_CAUSES, d.cause) : null;
|
|
173
|
+
if (d && cause) {
|
|
174
|
+
const count = (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1_000_000 ? v : 0);
|
|
175
|
+
out.diagnostic = {
|
|
176
|
+
cause,
|
|
177
|
+
bridgeCalls: count(d.bridgeCalls),
|
|
178
|
+
engineExit: typeof d.engineExit === "number" && Number.isInteger(d.engineExit) && d.engineExit >= -1 && d.engineExit <= 255 ? d.engineExit : 0,
|
|
179
|
+
activeMs: count(d.activeMs),
|
|
180
|
+
pages: count(d.pages),
|
|
181
|
+
filled: count(d.filled),
|
|
182
|
+
// Deduped from a closed 5-value vocabulary, so it is bounded by the enum itself — no cap needed.
|
|
183
|
+
signals: [...new Set((Array.isArray(d.signals) ? d.signals : []).filter((x) => LOCAL_APPLY_SIGNALS.includes(x)))],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
155
186
|
if (outcome === "blocked") {
|
|
156
187
|
out.blockReason = oneOf(LOCAL_APPLY_BLOCK_REASONS, o.blockReason) ?? "incomplete";
|
|
157
188
|
out.questions = (Array.isArray(o.questions) ? o.questions : [])
|
|
158
189
|
.filter((q) => typeof q === "string" && q.trim() !== "")
|
|
159
190
|
.slice(0, LOCAL_APPLY_CAPS.questions)
|
|
160
191
|
.map((q) => q.trim().slice(0, LOCAL_APPLY_CAPS.questionChars));
|
|
192
|
+
if (out.blockReason === "job_unavailable") {
|
|
193
|
+
const evidence = (o.unavailable && typeof o.unavailable === "object" && !Array.isArray(o.unavailable) ? o.unavailable : {});
|
|
194
|
+
const reason = oneOf(LOCAL_APPLY_UNAVAILABLE_REASONS, evidence.reason);
|
|
195
|
+
const url = str(evidence.url, 2000);
|
|
196
|
+
const observedAt = str(evidence.observedAt, 40);
|
|
197
|
+
if (!reason || !url || !isHttpUrl(url) || !observedAt || Number.isNaN(Date.parse(observedAt)) || evidence.source !== "page_notice") {
|
|
198
|
+
return { error: "a job_unavailable result needs runner-verified unavailable evidence (reason, url, observedAt, source)" };
|
|
199
|
+
}
|
|
200
|
+
out.unavailable = { reason, url, observedAt, source: "page_notice" };
|
|
201
|
+
}
|
|
202
|
+
else if (o.unavailable !== undefined)
|
|
203
|
+
return { error: "unavailable evidence is only valid for a job_unavailable result" };
|
|
161
204
|
}
|
|
205
|
+
else if (o.unavailable !== undefined)
|
|
206
|
+
return { error: "unavailable evidence is only valid for a job_unavailable result" };
|
|
162
207
|
if (outcome === "failed")
|
|
163
208
|
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
164
209
|
return { result: out };
|
|
@@ -40,10 +40,12 @@ export function applyPrompt(e, sources) {
|
|
|
40
40
|
...sources.map((s) => `<source name="${s.label}">\n${s.text}\n</source>\n`),
|
|
41
41
|
"How to work:",
|
|
42
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
|
+
`- Before any fill, upload, form-step click, or final submit, call ${t("supervisor_checkpoint")} and wait for its persisted continue directive. Call it after the initial snapshot (phase initial), after every page-changing move (phase post_navigation), and immediately before a final submit (phase before_submit). Use a stable safe checkpointId for each point. A page change clears the previous approval; request_review and stop end the run locally.`,
|
|
43
44
|
`- 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
45
|
`- 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
46
|
`- To attach a document, click the field's upload control, then call ${t("upload_artifact")} with kind resume or cover_letter.`,
|
|
46
47
|
"- Never sign in, solve a captcha, accept terms or work around any block; the run pauses for the owner by itself.",
|
|
48
|
+
`- If a fresh snapshot shows the job itself is expired, closed or unavailable, call ${t("report_job_unavailable")} with reason expired or unavailable. The runner will independently verify the page notice; do not report it from inference alone.`,
|
|
47
49
|
`- Stay on the application's site. Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
|
|
48
50
|
finish,
|
|
49
51
|
].join("\n");
|
|
@@ -147,6 +147,23 @@ export function verifyArtifact(workspace, home, a) {
|
|
|
147
147
|
return { error: `The approved ${label} at ${a.path} could not be read.` };
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* What to tell the owner when the CLI put nothing on the page (#975) — built from the diagnostic's
|
|
152
|
+
* own ids, so the advice follows the observation rather than guessing at it.
|
|
153
|
+
*/
|
|
154
|
+
function bridgeUnusedHelp(engine, d) {
|
|
155
|
+
if (d.signals.includes("approval_policy_blocked"))
|
|
156
|
+
return `The ${engine} CLI would not use the browser tools because its own approval policy refused them. Allow the PAGS bridge toolset for this session, then retry.`;
|
|
157
|
+
if (d.signals.includes("bridge_tools_missing"))
|
|
158
|
+
return `The ${engine} CLI never saw the browser tools. Update the CLI (npm i -g @proagentstore/cli), run \`pags up\` again, then retry.`;
|
|
159
|
+
if (d.signals.includes("auth_prompt"))
|
|
160
|
+
return `The ${engine} CLI stopped on a sign-in prompt before opening the page. Sign it in on this machine, then retry.`;
|
|
161
|
+
if (d.signals.includes("no_output"))
|
|
162
|
+
return `The ${engine} CLI produced no output at all before exiting. Check that it runs on this machine, then retry.`;
|
|
163
|
+
if (d.signals.includes("engine_refused_task"))
|
|
164
|
+
return `The ${engine} CLI declined the task and never opened the page. Its own closing message is on this run's summary.`;
|
|
165
|
+
return `The ${engine} CLI ran for ${Math.round(d.activeMs / 1000)}s and exited (code ${d.engineExit}) without opening the application page — no navigation, no checkpoint, no fill. Nothing was submitted. Retry, or run the CLI by hand on this machine to see why it stops.`;
|
|
166
|
+
}
|
|
150
167
|
export class LocalApplyRuntime {
|
|
151
168
|
deps;
|
|
152
169
|
runs = new Map();
|
|
@@ -194,6 +211,7 @@ export class LocalApplyRuntime {
|
|
|
194
211
|
engineAuth: "unknown",
|
|
195
212
|
cancelled: false,
|
|
196
213
|
timedOut: false,
|
|
214
|
+
checkpoints: new Map(),
|
|
197
215
|
};
|
|
198
216
|
this.runs.set(envelope.runId, run);
|
|
199
217
|
void this.launch(run, selfUrl).catch((err) => {
|
|
@@ -252,6 +270,7 @@ export class LocalApplyRuntime {
|
|
|
252
270
|
run.bridge = new ApplyBridge(run.browser.tools, {
|
|
253
271
|
emit: (ev) => this.emit(run, ev),
|
|
254
272
|
pause: (p) => this.pause(run, p),
|
|
273
|
+
supervisorCheckpoint: (checkpointId) => this.supervisorCheckpoint(run, checkpointId),
|
|
255
274
|
isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
|
|
256
275
|
grounding: () => run.grounding.join("\n"),
|
|
257
276
|
artifactPath: (kind) => {
|
|
@@ -302,6 +321,42 @@ export class LocalApplyRuntime {
|
|
|
302
321
|
}, 5_000);
|
|
303
322
|
run.timer.unref?.();
|
|
304
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* What the runner OBSERVED about its own CLI (#975), as ids this platform defines.
|
|
326
|
+
*
|
|
327
|
+
* Matched against the engine's structured output on the machine; only the matching signal's ID
|
|
328
|
+
* crosses the contract, never the line that matched it. That is what makes a diagnostic safe to
|
|
329
|
+
* persist: a CLI prints the owner's résumé prose and their typed answers while it works, and no
|
|
330
|
+
* redaction can reliably tell those from the rest of its chatter.
|
|
331
|
+
*/
|
|
332
|
+
signalsOf(run) {
|
|
333
|
+
const text = run.output.join("\n");
|
|
334
|
+
const out = [];
|
|
335
|
+
if (!text.trim())
|
|
336
|
+
out.push("no_output");
|
|
337
|
+
// #952's live failure, invisible from the cloud and fixable by configuration.
|
|
338
|
+
if (/approval policy is\s*[`'"]?never|requires? approval|permission denied for tool|not allowed to use/i.test(text))
|
|
339
|
+
out.push("approval_policy_blocked");
|
|
340
|
+
if (/browser_navigate.{0,40}(not found|unknown tool|unavailable)|no such tool|tool .{0,40}is not available/i.test(text))
|
|
341
|
+
out.push("bridge_tools_missing");
|
|
342
|
+
if (missingLogin(run.envelope.engine, text))
|
|
343
|
+
out.push("auth_prompt");
|
|
344
|
+
if (/\b(i (cannot|can't|won't)|unable to) (help|assist|complete|do that|proceed)/i.test(text))
|
|
345
|
+
out.push("engine_refused_task");
|
|
346
|
+
return [...new Set(out)];
|
|
347
|
+
}
|
|
348
|
+
/** The bounded, closed-vocabulary diagnostic for a run that put nothing on the page (#975). */
|
|
349
|
+
diagnosticOf(run, code, cause) {
|
|
350
|
+
return {
|
|
351
|
+
cause,
|
|
352
|
+
bridgeCalls: run.bridge?.actions ?? 0,
|
|
353
|
+
engineExit: Number.isInteger(code) && code >= -1 && code <= 255 ? code : 0,
|
|
354
|
+
activeMs: this.activeMs(run),
|
|
355
|
+
pages: run.bridge?.pages ?? 0,
|
|
356
|
+
filled: run.bridge?.filled ?? 0,
|
|
357
|
+
signals: this.signalsOf(run),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
305
360
|
/** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
|
|
306
361
|
outcomeOf(run, code) {
|
|
307
362
|
const e = run.envelope;
|
|
@@ -311,6 +366,8 @@ export class LocalApplyRuntime {
|
|
|
311
366
|
return { outcome: "failed", summary, error: "Cancelled by the owner" };
|
|
312
367
|
if (b?.submitted)
|
|
313
368
|
return { outcome: "submitted", summary, submitted: b.submitted };
|
|
369
|
+
if (b?.unavailable)
|
|
370
|
+
return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
|
|
314
371
|
if (b?.submitAttempted)
|
|
315
372
|
return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
|
|
316
373
|
if (b?.reviewReady)
|
|
@@ -319,10 +376,26 @@ export class LocalApplyRuntime {
|
|
|
319
376
|
return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
|
|
320
377
|
if (b?.blocked)
|
|
321
378
|
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.`] };
|
|
379
|
+
if (run.timedOut) {
|
|
380
|
+
return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`], diagnostic: this.diagnosticOf(run, code, "timed_out") };
|
|
381
|
+
}
|
|
382
|
+
// #975: a CLI that never called the bridge did NOTHING on the page, which is a different
|
|
383
|
+
// failure from stopping partway — different cause, different fix — and saying `incomplete`
|
|
384
|
+
// for both left the owner with a 51-second run they could not explain. The diagnostic says
|
|
385
|
+
// how long it ran, how many bridge calls it made (0) and what the runner saw it print.
|
|
386
|
+
const calls = b?.actions ?? 0;
|
|
387
|
+
if (calls === 0) {
|
|
388
|
+
const diagnostic = this.diagnosticOf(run, code, !run.output.join("").trim() ? "no_engine_output" : code !== 0 ? "engine_exited_nonzero" : "bridge_unused");
|
|
389
|
+
return { outcome: "blocked", summary, blockReason: "bridge_unused", questions: [bridgeUnusedHelp(e.engine, diagnostic)], diagnostic };
|
|
390
|
+
}
|
|
324
391
|
const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
|
|
325
|
-
return {
|
|
392
|
+
return {
|
|
393
|
+
outcome: "blocked",
|
|
394
|
+
summary,
|
|
395
|
+
blockReason: "incomplete",
|
|
396
|
+
questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`],
|
|
397
|
+
diagnostic: this.diagnosticOf(run, code, code !== 0 ? "engine_exited_nonzero" : "bridge_unused"),
|
|
398
|
+
};
|
|
326
399
|
}
|
|
327
400
|
activeMs(run) {
|
|
328
401
|
return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
|
|
@@ -343,6 +416,31 @@ export class LocalApplyRuntime {
|
|
|
343
416
|
}
|
|
344
417
|
return new Promise((done) => run.waiters.push(done));
|
|
345
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Wait for a cloud-brain decision without accepting a model's prose as permission. A directive
|
|
421
|
+
* can be stored before this call reaches its pause (the delivery race), but a normal `resume`
|
|
422
|
+
* can never release this pause.
|
|
423
|
+
*/
|
|
424
|
+
supervisorCheckpoint(run, supervisor) {
|
|
425
|
+
const { checkpointId } = supervisor;
|
|
426
|
+
let checkpoint = run.checkpoints.get(checkpointId);
|
|
427
|
+
if (!checkpoint) {
|
|
428
|
+
checkpoint = {};
|
|
429
|
+
run.checkpoints.set(checkpointId, checkpoint);
|
|
430
|
+
}
|
|
431
|
+
if (!checkpoint.checkpoint) {
|
|
432
|
+
checkpoint.checkpoint = supervisor;
|
|
433
|
+
this.emit(run, {
|
|
434
|
+
type: "supervisor.checkpoint",
|
|
435
|
+
...(supervisor.facts.url ? { url: supervisor.facts.url } : {}),
|
|
436
|
+
...(supervisor.facts.domain ? { domain: supervisor.facts.domain } : {}),
|
|
437
|
+
detail: { checkpointId, phase: supervisor.facts.phase, actions: supervisor.facts.actions, filled: supervisor.facts.filled, uploaded: supervisor.facts.uploaded },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (checkpoint.directive)
|
|
441
|
+
return Promise.resolve(checkpoint.directive);
|
|
442
|
+
return this.pause(run, { reason: "supervisor_checkpoint", checkpoint: supervisor }).then(() => run.checkpoints.get(checkpointId)?.directive ?? "stop");
|
|
443
|
+
}
|
|
346
444
|
release(run, outcome) {
|
|
347
445
|
if (run.state === "paused") {
|
|
348
446
|
run.state = "running";
|
|
@@ -360,6 +458,8 @@ export class LocalApplyRuntime {
|
|
|
360
458
|
const run = this.get(String(o.runId ?? ""));
|
|
361
459
|
if (run.state === "ended")
|
|
362
460
|
throw new RunnerInputError("The run has ended", 409);
|
|
461
|
+
if (run.pause?.reason === "supervisor_checkpoint")
|
|
462
|
+
throw new RunnerInputError("This run is waiting for a persisted supervisor directive, not an owner resume", 409);
|
|
363
463
|
for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
|
|
364
464
|
const r = (a && typeof a === "object" ? a : {});
|
|
365
465
|
if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
|
|
@@ -370,6 +470,41 @@ export class LocalApplyRuntime {
|
|
|
370
470
|
this.release(run, "resumed");
|
|
371
471
|
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
372
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Store then apply an immutable cloud directive. The caller persists it before this runner
|
|
475
|
+
* endpoint is reached; this in-process record makes delivery idempotent and closes the race
|
|
476
|
+
* where a directive arrives just before the bridge begins waiting.
|
|
477
|
+
*/
|
|
478
|
+
directive(raw) {
|
|
479
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
480
|
+
const run = this.get(typeof o.runId === "string" ? o.runId : "");
|
|
481
|
+
const checkpointId = typeof o.checkpointId === "string" && SAFE_ID.test(o.checkpointId) ? o.checkpointId : null;
|
|
482
|
+
const directive = o.directive === "continue" || o.directive === "request_review" || o.directive === "stop" ? o.directive : null;
|
|
483
|
+
if (o.schemaVersion !== 1 || !checkpointId || !directive)
|
|
484
|
+
throw new RunnerInputError("directive needs schemaVersion: 1, a safe checkpointId, and directive continue, request_review or stop");
|
|
485
|
+
if (run.state === "ended")
|
|
486
|
+
throw new RunnerInputError("The run has ended", 409);
|
|
487
|
+
let checkpoint = run.checkpoints.get(checkpointId);
|
|
488
|
+
if (checkpoint?.directive) {
|
|
489
|
+
if (checkpoint.directive !== directive)
|
|
490
|
+
throw new RunnerInputError("A different directive is already recorded for this checkpoint", 409);
|
|
491
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
492
|
+
}
|
|
493
|
+
checkpoint ??= {};
|
|
494
|
+
checkpoint.directive = directive;
|
|
495
|
+
run.checkpoints.set(checkpointId, checkpoint);
|
|
496
|
+
this.emit(run, { type: "supervisor.directive", detail: { checkpointId, directive } });
|
|
497
|
+
const waitingForThis = run.state === "paused" && run.pause?.reason === "supervisor_checkpoint" && run.pause.checkpoint?.checkpointId === checkpointId;
|
|
498
|
+
if (waitingForThis && directive === "continue")
|
|
499
|
+
this.release(run, "resumed");
|
|
500
|
+
else if (waitingForThis && directive === "request_review") {
|
|
501
|
+
this.end(run, { outcome: "awaiting_review", summary: "The supervisor requested an owner review." });
|
|
502
|
+
}
|
|
503
|
+
else if (waitingForThis && directive === "stop") {
|
|
504
|
+
this.end(run, { outcome: "blocked", blockReason: "incomplete", questions: ["The supervisor stopped this run before it could continue."] });
|
|
505
|
+
}
|
|
506
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
507
|
+
}
|
|
373
508
|
cancel(raw) {
|
|
374
509
|
const runId = String(raw?.runId ?? "");
|
|
375
510
|
const run = this.get(runId);
|
|
@@ -456,8 +591,11 @@ export class LocalApplyRuntime {
|
|
|
456
591
|
submitAttempted: b?.submitAttempted ?? false,
|
|
457
592
|
summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
|
|
458
593
|
...(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)) } : {}),
|
|
594
|
+
...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.blockReason === "job_unavailable" && r.unavailable ? { unavailable: r.unavailable } : {}) } : {}),
|
|
460
595
|
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
596
|
+
// #975 — carried through as the closed vocabulary it is. Nothing here is redacted because
|
|
597
|
+
// nothing here is text: counts, an exit code, and signal ids this platform defines.
|
|
598
|
+
...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
|
|
461
599
|
};
|
|
462
600
|
if (run.child && run.child.exitCode === null)
|
|
463
601
|
this.kill(run);
|
|
@@ -201,6 +201,9 @@ async function route(runner, req, res) {
|
|
|
201
201
|
if (req.method === "POST" && path === "/local-apply/resume") {
|
|
202
202
|
return json(res, 200, runner.localApply.resume(await readJson(req)));
|
|
203
203
|
}
|
|
204
|
+
if (req.method === "POST" && path === "/local-apply/directive") {
|
|
205
|
+
return json(res, 200, runner.localApply.directive(await readJson(req)));
|
|
206
|
+
}
|
|
204
207
|
if (req.method === "POST" && path === "/local-apply/cancel") {
|
|
205
208
|
return json(res, 200, runner.localApply.cancel(await readJson(req)));
|
|
206
209
|
}
|