@proagentstore/cli 0.4.53 → 0.4.55
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/apply-outcome.js +70 -0
- package/dist/browser-runner/coding/headless.js +29 -35
- package/dist/browser-runner/coding/transcript-lines.js +223 -0
- package/dist/browser-runner/commit-guard.js +127 -0
- package/dist/browser-runner/handoff-status.js +56 -0
- package/dist/browser-runner/runner.js +73 -23
- package/dist/browser-runner/server.js +5 -1
- package/dist/browser-runner/test-job-server.js +44 -2
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every outcome `/browser/complete` can be called with, and what it means locally.
|
|
3
|
+
*
|
|
4
|
+
* Only `cancelled` changed in #636. The rest are the behaviour that was already there, written
|
|
5
|
+
* down: `blocked` stays its own status because the agent stopped and needs the USER (email
|
|
6
|
+
* verification, something it cannot answer truthfully) — needs-attention, not a failure, and
|
|
7
|
+
* the console shows it as an active ticket rather than burying it.
|
|
8
|
+
*
|
|
9
|
+
* `captcha`/`stuck`/`needs_input` reach here only through the workflow's handoff TIMEOUT,
|
|
10
|
+
* which calls this endpoint with `failed` and a "… not resolved in time" detail — so their
|
|
11
|
+
* rows are a backstop for a direct call. They are `failed` because `TaskStatus` has no
|
|
12
|
+
* `escalated`, which is what the cloud's run record calls them; the honest local equivalent
|
|
13
|
+
* would be `needs_human`, but a task in that state is not finished, and every call to this
|
|
14
|
+
* method IS the finish. Left as-is deliberately rather than changed under an unrelated fix.
|
|
15
|
+
*/
|
|
16
|
+
export const APPLY_OUTCOME_DISPOSITION = {
|
|
17
|
+
submitted: { status: "completed", event: "task.completed", error: false },
|
|
18
|
+
ready: { status: "completed", event: "task.completed", error: false },
|
|
19
|
+
expired: { status: "completed", event: "task.completed", error: false },
|
|
20
|
+
blocked: { status: "blocked", event: "task.failed", error: true },
|
|
21
|
+
captcha: { status: "failed", event: "task.failed", error: true },
|
|
22
|
+
stuck: { status: "failed", event: "task.failed", error: true },
|
|
23
|
+
needs_input: { status: "failed", event: "task.failed", error: true },
|
|
24
|
+
failed: { status: "failed", event: "task.failed", error: true },
|
|
25
|
+
max_steps: { status: "failed", event: "task.failed", error: true },
|
|
26
|
+
cancelled: { status: "cancelled", event: "task.cancelled", error: false },
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The disposition for an outcome off the wire.
|
|
30
|
+
*
|
|
31
|
+
* `outcome` is a string from an HTTP body: a newer cloud can send one this bundled runner has
|
|
32
|
+
* never heard of, since the runner ships inside a CLI the user upgrades on their own schedule.
|
|
33
|
+
* An unknown outcome falls back to `failed` — the same `?? "failed"` the cloud's
|
|
34
|
+
* `browserRunStopReason` uses — because "something ended and I cannot say it went well" is the
|
|
35
|
+
* only honest reading of a word this build does not know.
|
|
36
|
+
*/
|
|
37
|
+
export function dispositionForOutcome(outcome) {
|
|
38
|
+
return APPLY_OUTCOME_DISPOSITION[outcome] ?? APPLY_OUTCOME_DISPOSITION.failed;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Write a finished run's outcome onto the task, and name the event to append.
|
|
42
|
+
*
|
|
43
|
+
* Pure apart from the mutation of the task it is handed, so the whole disposition — status,
|
|
44
|
+
* error-or-not, which event — is decided in one tested place instead of inside a method that
|
|
45
|
+
* also detaches CDP sessions and closes pages.
|
|
46
|
+
*/
|
|
47
|
+
export function settleTaskOutcome(task, outcome, detail) {
|
|
48
|
+
const disposition = dispositionForOutcome(outcome);
|
|
49
|
+
task.status = disposition.status;
|
|
50
|
+
task.output = { outcome, detail };
|
|
51
|
+
if (disposition.error)
|
|
52
|
+
task.error = detail || outcome;
|
|
53
|
+
task.updatedAt = new Date().toISOString();
|
|
54
|
+
task.completedAt = task.updatedAt;
|
|
55
|
+
return disposition;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The statuses a task cannot be moved out of — what `cancelTask` refuses to overwrite.
|
|
59
|
+
*
|
|
60
|
+
* `blocked` is deliberately NOT here: a blocked task is waiting on the owner, and Stop is the
|
|
61
|
+
* owner answering. `cancelled` IS here, and was the member the old `completed || failed` pair
|
|
62
|
+
* missed (#636) — a second Stop rewrote the timestamps and appended a duplicate
|
|
63
|
+
* `task.cancelled`. `completed` and `failed` stay untouchable in the other direction: a run
|
|
64
|
+
* that genuinely finished must not be relabelled as cancelled, because it was not.
|
|
65
|
+
*/
|
|
66
|
+
export const TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
|
|
67
|
+
/** Has this task already finished, whichever way it finished? */
|
|
68
|
+
export function isTerminalTaskStatus(status) {
|
|
69
|
+
return TERMINAL_TASK_STATUSES.includes(status);
|
|
70
|
+
}
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk, toolResultMark } from "./engine-acts.js";
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
4
|
import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
5
|
+
import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
|
|
5
6
|
/**
|
|
6
7
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
7
8
|
* REMOVE rather than "set to empty".
|
|
@@ -115,6 +116,15 @@ export class HeadlessSession {
|
|
|
115
116
|
* damages the record exactly as much as missing a real one.
|
|
116
117
|
*/
|
|
117
118
|
awaitingResult = new Map();
|
|
119
|
+
/**
|
|
120
|
+
* `tool_use_id` → tool name, for the ONE decision the result event cannot make on its own: how
|
|
121
|
+
* much of it reaches the pane (#700, `toolResultBudget` in `transcript-lines.ts`).
|
|
122
|
+
*
|
|
123
|
+
* Separate from {@link awaitingResult}, which holds only the small minority of calls that
|
|
124
|
+
* classify as consequential acts. Entries are deleted when their result arrives and the whole
|
|
125
|
+
* map is cleared at the turn boundary, where a call that never got one is known never to.
|
|
126
|
+
*/
|
|
127
|
+
toolNames = new Map();
|
|
118
128
|
/** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
|
|
119
129
|
usageSeq = 0;
|
|
120
130
|
/**
|
|
@@ -597,7 +607,13 @@ export class HeadlessSession {
|
|
|
597
607
|
this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
|
|
598
608
|
}
|
|
599
609
|
else if (block.type === "tool_use") {
|
|
600
|
-
|
|
610
|
+
const name = String(block.name ?? "tool");
|
|
611
|
+
this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
|
|
612
|
+
// The result arrives in a LATER event carrying only `tool_use_id`, and how much
|
|
613
|
+
// of it reaches the pane depends on which tool it was (#700) — so the name is
|
|
614
|
+
// remembered here and read back in `settleAct`'s sibling branch below.
|
|
615
|
+
if (typeof block.id === "string" && block.id)
|
|
616
|
+
this.toolNames.set(block.id, name);
|
|
601
617
|
this.noteAct(block);
|
|
602
618
|
}
|
|
603
619
|
}
|
|
@@ -605,7 +621,12 @@ export class HeadlessSession {
|
|
|
605
621
|
case "user": // tool results come back as a synthetic user message
|
|
606
622
|
for (const block of ev.message?.content ?? []) {
|
|
607
623
|
if (block.type === "tool_result") {
|
|
608
|
-
|
|
624
|
+
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
625
|
+
// `""` when the call was not seen (a pane that began mid-turn, a runner restart):
|
|
626
|
+
// an unknown tool takes the conservative budget rather than the generous one.
|
|
627
|
+
const tool = this.toolNames.get(id) ?? "";
|
|
628
|
+
this.toolNames.delete(id);
|
|
629
|
+
this.push(renderToolResult(toolResultMark(block), block.content, tool)); // ↳✓ / ↳✗ (#597)
|
|
609
630
|
this.settleAct(block);
|
|
610
631
|
}
|
|
611
632
|
}
|
|
@@ -633,13 +654,16 @@ export class HeadlessSession {
|
|
|
633
654
|
// never saw whether it worked" is a materially different claim from silence, and
|
|
634
655
|
// silence is what a supervisor would read as "it did nothing".
|
|
635
656
|
this.flushAwaitingActs();
|
|
657
|
+
this.toolNames.clear(); // no further result is coming for anything still named here
|
|
636
658
|
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
637
659
|
break;
|
|
638
660
|
}
|
|
639
661
|
default:
|
|
640
662
|
break;
|
|
641
663
|
}
|
|
642
|
-
// Keep the in-memory transcript bounded.
|
|
664
|
+
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
665
|
+
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
666
|
+
// character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
|
|
643
667
|
if (this.transcript.length > 4000)
|
|
644
668
|
this.transcript = this.transcript.slice(-3000);
|
|
645
669
|
}
|
|
@@ -665,8 +689,8 @@ export class HeadlessSession {
|
|
|
665
689
|
*
|
|
666
690
|
* The result carries more than the outcome: `gh pr create --fill` states its PR number nowhere
|
|
667
691
|
* but its own stdout, so an unnumbered `pr.open`/`pr.merge` takes it from here (#417). It is read
|
|
668
|
-
* from the RAW `block.content`, never from `
|
|
669
|
-
*
|
|
692
|
+
* from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
|
|
693
|
+
* the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
|
|
670
694
|
*
|
|
671
695
|
* This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
|
|
672
696
|
* (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
|
|
@@ -820,36 +844,6 @@ export function buildClaudeArgs(userArgs, resumeId) {
|
|
|
820
844
|
args.push("--resume", resumeId);
|
|
821
845
|
return args;
|
|
822
846
|
}
|
|
823
|
-
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
824
|
-
function stripAnsi(s) {
|
|
825
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
|
|
826
|
-
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
|
827
|
-
}
|
|
828
|
-
/** Compact a tool_use input object to a single readable line. */
|
|
829
|
-
function shortInput(input) {
|
|
830
|
-
if (input == null)
|
|
831
|
-
return "";
|
|
832
|
-
try {
|
|
833
|
-
const s = typeof input === "string" ? input : JSON.stringify(input);
|
|
834
|
-
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
835
|
-
}
|
|
836
|
-
catch {
|
|
837
|
-
return "";
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
/** Render a tool_result's content (string, or array of {type:text,text}) to text. */
|
|
841
|
-
function toolResult(content) {
|
|
842
|
-
let text = "";
|
|
843
|
-
if (typeof content === "string")
|
|
844
|
-
text = content;
|
|
845
|
-
else if (Array.isArray(content)) {
|
|
846
|
-
text = content
|
|
847
|
-
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
848
|
-
.join(" ");
|
|
849
|
-
}
|
|
850
|
-
text = text.replace(/\s+/g, " ").trim();
|
|
851
|
-
return text.length > 240 ? `${text.slice(0, 240)}…` : text;
|
|
852
|
-
}
|
|
853
847
|
function loadFile(path) {
|
|
854
848
|
if (!path || !existsSync(path))
|
|
855
849
|
return {};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an engine event becomes a line in the pane — the runner's half of #700.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect this file exists to remove
|
|
5
|
+
*
|
|
6
|
+
* `headless.ts` rendered every `tool_result` through a four-line helper that did two things before
|
|
7
|
+
* the pane existed: it collapsed `\s+` to a single space, and it cut the text to 240 characters.
|
|
8
|
+
* Both losses were total and neither was disclosed beyond a trailing ellipsis.
|
|
9
|
+
*
|
|
10
|
+
* The consequence is not "results are short". It is that **the shape of a result no longer depends
|
|
11
|
+
* on the command that produced it.** `cat`, `sed -n '1,50p'`, `cat -n | head -60` and "print every
|
|
12
|
+
* character" all arrive at the same size, on one line, with the indentation gone. A Pilot that
|
|
13
|
+
* cannot see a file therefore has no move: every rephrasing it can reach is the same request with
|
|
14
|
+
* the same bound. One live run spent twelve of its sixteen decisions searching that empty space and
|
|
15
|
+
* concluded — reasonably, on the evidence in front of it — that "the CLI is summarizing file
|
|
16
|
+
* contents". It was not; the evidence had been destroyed upstream.
|
|
17
|
+
*
|
|
18
|
+
* Measured across 20 sessions of one instance: **150 of 251 stored tool-result lines (59.8%) hit
|
|
19
|
+
* the 240-character cap** — 102 `Bash`, 43 `Read` — with a maximum line length of 246. The cap was
|
|
20
|
+
* the common case, and it fired hardest on `Read`, the tool whose entire job is returning file
|
|
21
|
+
* contents.
|
|
22
|
+
*
|
|
23
|
+
* ── What replaces it, and what it costs
|
|
24
|
+
*
|
|
25
|
+
* Three changes, and the reason each is separate:
|
|
26
|
+
*
|
|
27
|
+
* 1. **Line structure survives.** Newlines are kept and continuation lines carry
|
|
28
|
+
* {@link RESULT_CONT_PREFIX}. This is the half that matters most and it costs almost nothing:
|
|
29
|
+
* `cat -n` output is unreadable as one space-joined line at ANY length, so the collapse made
|
|
30
|
+
* the cap worse than its size alone implies.
|
|
31
|
+
* 2. **The cap is per tool** ({@link toolResultBudget}), not one number. A `Read` result is the
|
|
32
|
+
* case that needs room; an `Edit`'s "has been updated successfully" is a status string that was
|
|
33
|
+
* never near 240 anyway. Raising one number for everything would buy file text by spending the
|
|
34
|
+
* pane on results that carry none.
|
|
35
|
+
* 3. **A cut states its own size** (`…[cut: 1,500 of 18,432 chars]`), so the reader learns there
|
|
36
|
+
* is more AND how much — which is what makes "ask for a slice that fits" a strategy rather than
|
|
37
|
+
* a guess. Same disclosure discipline as `repo_read_file`'s window header and `repo_git`'s
|
|
38
|
+
* TRUNCATED note on the cloud side.
|
|
39
|
+
*
|
|
40
|
+
* **The cost is real and it is the pane.** A `terminal` row stores the last 8,000 characters
|
|
41
|
+
* (`TERMINAL_SNAPSHOT_CHARS`) and one Pilot decision reads the last 6,000 (`PILOT_PANE_CHARS`), so
|
|
42
|
+
* every character bought for file text is bought by evicting history — including the `⚙`/`↳`
|
|
43
|
+
* framing `engine-tool-calls.ts` parses back out and the engine-error lines #580 exists to keep
|
|
44
|
+
* visible. {@link RESULT_CAP_CONTENT_CHARS} is therefore derived from that window rather than
|
|
45
|
+
* chosen from it — see {@link RESULT_CAP_CONTENT_CHARS} and {@link RESULT_RENDERED_MAX}, which
|
|
46
|
+
* state the content budget and what it actually costs the pane once framing is paid for.
|
|
47
|
+
*
|
|
48
|
+
* ── Old runners, and why nothing gates on a version
|
|
49
|
+
*
|
|
50
|
+
* A machine that has not upgraded keeps the 240-char single-line behaviour, so every cloud reader
|
|
51
|
+
* must go on tolerating short, structureless results — `engine-tool-calls.ts` already models that
|
|
52
|
+
* (`outputCut`) and continues to. Continuation lines are read by their PREFIX, not by a version
|
|
53
|
+
* probe: a pane is judged by what it contains, the way the `↳✓` outcome marker is.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* The prefix every line of a result after the first carries.
|
|
57
|
+
*
|
|
58
|
+
* It exists for two reasons and the second is the load-bearing one:
|
|
59
|
+
*
|
|
60
|
+
* - the pane stays readable as a transcript rather than as an unindented dump, and
|
|
61
|
+
* - a result can no longer FORGE the framing. `parseEngineToolCalls` decides what a line is from
|
|
62
|
+
* its first characters, so a file containing a line that begins `⚙ ` or `↳` would, once
|
|
63
|
+
* newlines survived, parse as a tool call the engine never made. Every continuation line begins
|
|
64
|
+
* with this prefix instead, so a result's own text can never start a line that the parser reads
|
|
65
|
+
* as anything else.
|
|
66
|
+
*
|
|
67
|
+
* `│` (U+2502), not `|`: a markdown table in an engine's reply uses the ASCII pipe, and the point
|
|
68
|
+
* of the character is that transcript prose does not use it.
|
|
69
|
+
*/
|
|
70
|
+
export const RESULT_CONT_PREFIX = " │ ";
|
|
71
|
+
/**
|
|
72
|
+
* How much of a CONTENT tool's result — its own characters, framing excluded — reaches the pane.
|
|
73
|
+
*
|
|
74
|
+
* Derived, not picked: the Pilot reads the last 6,000 characters of the pane
|
|
75
|
+
* (`PILOT_PANE_CHARS`, `workers/api/src/lib/coding-repetition.ts`), and no single result may take
|
|
76
|
+
* more than a QUARTER of that in content, which is ~35 lines of ordinary source — what a bounded
|
|
77
|
+
* slice request actually asks for. What that costs the pane in full is {@link RESULT_RENDERED_MAX},
|
|
78
|
+
* and it is the larger number, because framing is not free.
|
|
79
|
+
*
|
|
80
|
+
* The two constants are separate declarations in separate packages for the reason `RESULT_ARROW` is
|
|
81
|
+
* (a Worker must not import the runner's Node package); the derivation is stated here so that
|
|
82
|
+
* moving one without the other is at least a visible mistake.
|
|
83
|
+
*
|
|
84
|
+
* `used` and `total` in the cut notice are BOTH content characters, deliberately: the number is
|
|
85
|
+
* only actionable if the model can compare it against the file it is asking for.
|
|
86
|
+
*/
|
|
87
|
+
export const RESULT_CAP_CONTENT_CHARS = 1500;
|
|
88
|
+
/**
|
|
89
|
+
* A ceiling on LINES as well as characters, because the prefix is charged per line.
|
|
90
|
+
*
|
|
91
|
+
* A result of 1,500 one-character lines would pay {@link RESULT_CONT_PREFIX} 1,500 times — 6,000
|
|
92
|
+
* characters of framing for 1,500 of content, i.e. the whole of the Pilot's window spent on
|
|
93
|
+
* indentation. 60 lines bounds that overhead at 240 characters.
|
|
94
|
+
*/
|
|
95
|
+
export const RESULT_CAP_CONTENT_LINES = 60;
|
|
96
|
+
/** Every other tool keeps the historical 240 characters — see {@link CONTENT_TOOLS}. */
|
|
97
|
+
export const RESULT_CAP_DEFAULT_CHARS = 240;
|
|
98
|
+
/** …and at most six lines of it, so a status string cannot spend six prefixes to say nothing. */
|
|
99
|
+
export const RESULT_CAP_DEFAULT_LINES = 6;
|
|
100
|
+
/** The longest cut notice the renderer can emit — `…[cut: 999,999,999 of 999,999,999 chars]`. */
|
|
101
|
+
const CUT_NOTICE_MAX = 45;
|
|
102
|
+
/**
|
|
103
|
+
* What the WHOLE result block can cost the pane, framing and disclosure included.
|
|
104
|
+
*
|
|
105
|
+
* Stated because the content budget alone is not the honest figure and pretending otherwise is how
|
|
106
|
+
* a cap gets quietly exceeded: the arrow prefix, one `RESULT_CONT_PREFIX` and one newline per
|
|
107
|
+
* continuation line, and the cut notice are all pane characters too. At the current numbers that is
|
|
108
|
+
* ~1,840 — under a THIRD of the Pilot's 6,000-character window, so three consecutive content
|
|
109
|
+
* results still coexist with the narrative and the `⚙`/`↳` framing rather than evicting them.
|
|
110
|
+
*
|
|
111
|
+
* If someone widens {@link RESULT_CAP_CONTENT_CHARS}, this is the number that says what it costs.
|
|
112
|
+
*/
|
|
113
|
+
export const RESULT_RENDERED_MAX = " ↳✓ ".length + RESULT_CAP_CONTENT_CHARS + (RESULT_CAP_CONTENT_LINES - 1) * (RESULT_CONT_PREFIX.length + 1) + CUT_NOTICE_MAX;
|
|
114
|
+
/**
|
|
115
|
+
* The tools whose RESULT is the answer, rather than a receipt for an action.
|
|
116
|
+
*
|
|
117
|
+
* `Read`/`Bash` are the two the measurement names (43 and 102 of the 150 truncated lines).
|
|
118
|
+
* `Grep`/`Glob`/`BashOutput`/`NotebookRead` are here because their results are line-structured
|
|
119
|
+
* listings — the same content, arriving through a different tool name — and a Pilot that asked for
|
|
120
|
+
* a grep instead of a cat should not be punished for it.
|
|
121
|
+
*
|
|
122
|
+
* `Edit`, `Write`, `MultiEdit` and `TodoWrite` are deliberately absent: their results are status
|
|
123
|
+
* strings ("has been updated successfully…", 176-214 characters in the sampled sessions) that the
|
|
124
|
+
* old cap barely touched, so widening them would spend the pane and buy nothing.
|
|
125
|
+
*/
|
|
126
|
+
export const CONTENT_TOOLS = new Set(["Read", "Bash", "BashOutput", "Grep", "Glob", "NotebookRead"]);
|
|
127
|
+
/** The budget for one tool's result. An unknown or unnamed tool gets the conservative one. */
|
|
128
|
+
export function toolResultBudget(tool) {
|
|
129
|
+
return CONTENT_TOOLS.has(tool)
|
|
130
|
+
? { chars: RESULT_CAP_CONTENT_CHARS, lines: RESULT_CAP_CONTENT_LINES }
|
|
131
|
+
: { chars: RESULT_CAP_DEFAULT_CHARS, lines: RESULT_CAP_DEFAULT_LINES };
|
|
132
|
+
}
|
|
133
|
+
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
134
|
+
export function stripAnsi(s) {
|
|
135
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
|
|
136
|
+
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
|
137
|
+
}
|
|
138
|
+
/** Compact a tool_use input object to a single readable line. */
|
|
139
|
+
export function shortInput(input) {
|
|
140
|
+
if (input == null)
|
|
141
|
+
return "";
|
|
142
|
+
try {
|
|
143
|
+
const s = typeof input === "string" ? input : JSON.stringify(input);
|
|
144
|
+
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Render a tool_result's content (string, or array of `{type:text,text}`) to raw text. */
|
|
151
|
+
export function toolResultText(content) {
|
|
152
|
+
if (typeof content === "string")
|
|
153
|
+
return content;
|
|
154
|
+
if (!Array.isArray(content))
|
|
155
|
+
return "";
|
|
156
|
+
// Joined with a NEWLINE, not a space: separate content blocks are separate pieces of output,
|
|
157
|
+
// and the whole point of this module is that the difference is preserved.
|
|
158
|
+
return content
|
|
159
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
160
|
+
.filter((t) => t !== "")
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Normalise a result's text to display lines: no CR, no escape codes, no trailing whitespace, and
|
|
165
|
+
* no runs of blank lines. Every one of those is pane budget spent on nothing.
|
|
166
|
+
*/
|
|
167
|
+
function tidyLines(text) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const raw of stripAnsi(text.replace(/\r\n?/g, "\n")).split("\n")) {
|
|
170
|
+
const line = raw.trimEnd();
|
|
171
|
+
// Drop a leading blank, and collapse a run of blanks to one.
|
|
172
|
+
if (!line && (out.length === 0 || out[out.length - 1] === ""))
|
|
173
|
+
continue;
|
|
174
|
+
out.push(line);
|
|
175
|
+
}
|
|
176
|
+
while (out.length && out[out.length - 1] === "")
|
|
177
|
+
out.pop();
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The full transcript entry for one `tool_result` — the arrow line plus any continuation lines.
|
|
182
|
+
*
|
|
183
|
+
* The head is cut rather than the tail, unchanged from the old helper: a file, a listing and a
|
|
184
|
+
* grep all answer from their beginning, and a test run's tail is recoverable from the engine's own
|
|
185
|
+
* reply text, which is never truncated. Changing that is a separate decision and is not made here.
|
|
186
|
+
*/
|
|
187
|
+
export function renderToolResult(mark, content, tool) {
|
|
188
|
+
const lines = tidyLines(toolResultText(content));
|
|
189
|
+
const budget = toolResultBudget(tool);
|
|
190
|
+
const total = lines.join("\n").length;
|
|
191
|
+
const kept = [];
|
|
192
|
+
let used = 0;
|
|
193
|
+
let cut = false;
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
if (kept.length >= budget.lines) {
|
|
196
|
+
cut = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
const room = budget.chars - used;
|
|
200
|
+
if (room <= 0) {
|
|
201
|
+
cut = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (line.length > room) {
|
|
205
|
+
kept.push(line.slice(0, room));
|
|
206
|
+
used += room;
|
|
207
|
+
cut = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
kept.push(line);
|
|
211
|
+
used += line.length;
|
|
212
|
+
}
|
|
213
|
+
if (cut && kept.length) {
|
|
214
|
+
// The figures, not just the ellipsis: "there was more" tells the reader to give up, "1,500
|
|
215
|
+
// of 18,432" tells it how narrow a slice would arrive whole. The ellipsis stays because it
|
|
216
|
+
// is what every runner ever written has used to mark a cut, and readers key on it.
|
|
217
|
+
kept[kept.length - 1] = `${kept[kept.length - 1]}…[cut: ${used.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} chars]`;
|
|
218
|
+
}
|
|
219
|
+
const head = ` ↳${mark} ${kept[0] ?? ""}`.trimEnd();
|
|
220
|
+
if (kept.length < 2)
|
|
221
|
+
return head;
|
|
222
|
+
return [head, ...kept.slice(1).map((l) => `${RESULT_CONT_PREFIX}${l}`)].join("\n");
|
|
223
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit guard AT THE ACT BOUNDARY — the runner half of #627 / #629.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it has to be here
|
|
5
|
+
*
|
|
6
|
+
* `dryRun` and `readOnly` were enforced entirely in the cloud, on `action.name`: a string the MODEL
|
|
7
|
+
* wrote ABOUT the element. This process is the one that actually clicks, and it clicks by `ref`
|
|
8
|
+
* ({@link https://github.com/microsoft/playwright-mcp} `browser_click({ element, target })` — only
|
|
9
|
+
* `target` locates; `element` is a human-readable description). So the guard tested a story about
|
|
10
|
+
* the act while the act went somewhere else entirely, and the runner — the only party holding the
|
|
11
|
+
* DOM, and therefore the only one that can KNOW whether a control submits — had never been told
|
|
12
|
+
* that a run was a rehearsal at all: `grep -rn dryRun packages/browser-runner/src` returned eight
|
|
13
|
+
* hits and every one of them was the orphaned-browser reaper's unrelated `--dry-run` flag.
|
|
14
|
+
*
|
|
15
|
+
* A nameless click had already submitted a real application during a run the owner asked to be a
|
|
16
|
+
* test. The fix at the time made an EMPTY name a refusal, which left a WRONG name — a paraphrase,
|
|
17
|
+
* an `aria-label` the model rewrote, a page in French — behaving exactly as before.
|
|
18
|
+
*
|
|
19
|
+
* ── What is a fact here and what is still a guess
|
|
20
|
+
*
|
|
21
|
+
* FACT (`read_only`): whether the targeted control submits a form, and whether that form is a POST.
|
|
22
|
+
* Language-independent, label-independent, and not something the brain can talk its way past. A
|
|
23
|
+
* GET form is a search — the read-only prompt explicitly allows finding things — so only POST is
|
|
24
|
+
* refused. Enter and Space are checked against the FOCUSED element the same way.
|
|
25
|
+
*
|
|
26
|
+
* GUESS (both rehearsal modes): which of several POST submits on a multi-page ATS is the FINAL one.
|
|
27
|
+
* Nothing in the DOM says so — "Save and Continue" on page 3 and "Submit application" on page 6 are
|
|
28
|
+
* the same kind of control — and a rehearsal must be able to walk the whole form. So a rehearsal
|
|
29
|
+
* still decides on a LABEL. What changed is whose label: the element's own accessible name, read
|
|
30
|
+
* back out of the live DOM, instead of the model's claim about it, and a vocabulary that is not
|
|
31
|
+
* English-only. That residual is recorded on the issue rather than papered over.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The FLOOR vocabulary, used only when the cloud sent none. It is deliberately the read-only
|
|
35
|
+
* (widest) set: a runner that has been told "read_only" but not told the words must not fail open.
|
|
36
|
+
* The authoritative list is `workers/api/src/lib/commit-guard.ts`, and `commit-guard.test.ts` there
|
|
37
|
+
* asserts this file still parses as a regex, so a copy that rots is a red build rather than a
|
|
38
|
+
* silent downgrade.
|
|
39
|
+
*/
|
|
40
|
+
export const FALLBACK_COMMIT_RE = /\b(confirm|accept|submit|send|post|publish|delete|remove|pay|purchase|buy|apply|approve|agree|save)\b|(?<![\p{L}\p{N}])(envoyer|soumettre|valider|absenden|abschicken|senden|einreichen|enviar|invia|inviare|verstuur|versturen|verzenden|indienen|skicka|wy[śs]lij|g[öo]nder|kirim|отправить)(?![\p{L}\p{N}])|提交|送出|确认|確認|送信|提出|제출|보내기|إرسال|تقديم/iu;
|
|
41
|
+
/** Compile the policy the cloud sent, falling back to the floor above. A malformed pattern must
|
|
42
|
+
* NOT disarm the guard — it falls back rather than throwing, because the caller is about to act. */
|
|
43
|
+
export function commitLabelRe(spec) {
|
|
44
|
+
if (!spec?.labels)
|
|
45
|
+
return FALLBACK_COMMIT_RE;
|
|
46
|
+
try {
|
|
47
|
+
return new RegExp(spec.labels, spec.flags || "iu");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return FALLBACK_COMMIT_RE;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Keys that submit a focused form. `NumpadEnter` submits identically and was matched by nothing. */
|
|
54
|
+
export const SUBMIT_KEY_RE = /^(enter|return|numpadenter)$/i;
|
|
55
|
+
/** Space activates the focused control, including a submit button. */
|
|
56
|
+
export const ACTIVATE_KEY_RE = /^(space|spacebar|\s)$/i;
|
|
57
|
+
/** The probe evaluated ON the targeted element. Kept as source text because it crosses into the
|
|
58
|
+
* page through the standard `browser_evaluate` tool, which takes a function expression. */
|
|
59
|
+
export const ELEMENT_PROBE_FN = `el => {
|
|
60
|
+
var btn = (el.closest && el.closest('button,input,a,[role=button]')) || el;
|
|
61
|
+
var tag = (btn.tagName || '').toLowerCase();
|
|
62
|
+
var type = ((btn.getAttribute && btn.getAttribute('type')) || '').toLowerCase();
|
|
63
|
+
var form = btn.form || (btn.closest && btn.closest('form')) || null;
|
|
64
|
+
var submits = !!form && ((tag === 'button' && type !== 'button' && type !== 'reset') || (tag === 'input' && (type === 'submit' || type === 'image')));
|
|
65
|
+
var name = (btn.getAttribute && btn.getAttribute('aria-label')) || btn.value || btn.innerText || btn.textContent || '';
|
|
66
|
+
return { submits: submits, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: tag, type: type, name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
|
|
67
|
+
}`;
|
|
68
|
+
/** The probe for a keypress: there is no ref, so it reads whatever has focus. */
|
|
69
|
+
export const FOCUS_PROBE_FN = `(() => {
|
|
70
|
+
var el = document.activeElement;
|
|
71
|
+
if (!el) return { inForm: false, method: '', tag: '', type: '', name: '' };
|
|
72
|
+
var form = el.form || (el.closest && el.closest('form')) || null;
|
|
73
|
+
var name = (el.getAttribute && el.getAttribute('aria-label')) || el.value || el.innerText || el.textContent || '';
|
|
74
|
+
return { inForm: !!form, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: (el.tagName || '').toLowerCase(), type: ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
|
|
75
|
+
})()`;
|
|
76
|
+
/**
|
|
77
|
+
* May this click reach the page? Returns the refusal to hand back to the brain, or null.
|
|
78
|
+
*
|
|
79
|
+
* `facts` is null when the element could not be probed (an evaluate that failed, a ref the page no
|
|
80
|
+
* longer has). That is not treated as permission: in read-only it is refused outright, and in a
|
|
81
|
+
* rehearsal it falls back to the claimed name, which is the behaviour that shipped.
|
|
82
|
+
*/
|
|
83
|
+
export function refuseClick(spec, facts, claimedName, re) {
|
|
84
|
+
const claimed = (claimedName ?? "").trim();
|
|
85
|
+
const real = (facts?.name ?? "").trim();
|
|
86
|
+
if (spec.mode === "read_only") {
|
|
87
|
+
if (!facts) {
|
|
88
|
+
return "BLOCKED by the runner — this agent is READ-ONLY and that element could not be read from the page, so the click cannot be shown to be safe. Re-read the snapshot and target an element from it.";
|
|
89
|
+
}
|
|
90
|
+
if (facts.submits && facts.method === "post") {
|
|
91
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and "${real || claimed || facts.tag}" submits a form (POST) on this page. That is a change, whatever the control is called. Report what you can already see with finish.`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const hit = [real, claimed].find((n) => n && re.test(n));
|
|
95
|
+
if (!hit)
|
|
96
|
+
return null;
|
|
97
|
+
return spec.mode === "read_only"
|
|
98
|
+
? `BLOCKED by the runner — this agent is READ-ONLY and can never perform "${hit}". Report what you can already see with finish.`
|
|
99
|
+
: `BLOCKED by the runner — this is a REHEARSAL and "${hit}" commits. The page never received the click. Call finish now instead of retrying.`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* May this keypress reach the page?
|
|
103
|
+
*
|
|
104
|
+
* Read-only only. A rehearsal's Enter is decided by the cloud loop, which holds the one piece of
|
|
105
|
+
* state that makes the call — an Enter immediately after an arrow key is an autocomplete ACCEPT,
|
|
106
|
+
* not a submit — and this process cannot tell a JS listbox that will swallow the key from a form
|
|
107
|
+
* that will take it.
|
|
108
|
+
*/
|
|
109
|
+
export function refuseKey(spec, key, focus) {
|
|
110
|
+
if (spec.mode !== "read_only")
|
|
111
|
+
return null;
|
|
112
|
+
const k = (key ?? "").trim();
|
|
113
|
+
const submitKey = SUBMIT_KEY_RE.test(k);
|
|
114
|
+
const activateKey = ACTIVATE_KEY_RE.test(k);
|
|
115
|
+
if (!submitKey && !activateKey)
|
|
116
|
+
return null;
|
|
117
|
+
if (!focus) {
|
|
118
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and the focused element could not be read, so pressing ${k || "Enter"} cannot be shown to be safe.`;
|
|
119
|
+
}
|
|
120
|
+
if (submitKey && focus.inForm && focus.method === "post") {
|
|
121
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and pressing ${k} submits the form this field belongs to (POST). A search or filter that only reads (GET) is fine; this one writes. Report what you can already see with finish.`;
|
|
122
|
+
}
|
|
123
|
+
if (activateKey && (focus.tag === "button" || focus.type === "submit") && focus.method === "post") {
|
|
124
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and Space activates "${focus.name || "the focused button"}", which submits a form (POST).`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a handoff-status poll can answer WITHOUT looking at the live page (#641).
|
|
3
|
+
*
|
|
4
|
+
* A handoff is the runner asking a human for something the brain cannot do: solve a
|
|
5
|
+
* challenge, perform one widget step it failed at, or supply a value it must not invent.
|
|
6
|
+
* The remote Workflow polls `browserHandoffStatus` until it is told the human acted, and
|
|
7
|
+
* `solved: true` is a licence to resume the application — so the only safe answers are the
|
|
8
|
+
* ones the runner can actually stand behind.
|
|
9
|
+
*
|
|
10
|
+
* ── The rule
|
|
11
|
+
*
|
|
12
|
+
* A handoff whose PAGE IS GONE is LOST, not resolved. It is the same category as a handoff
|
|
13
|
+
* whose SESSION is gone (the runner restarted, the takeover expired), which the caller
|
|
14
|
+
* already answers `solved: false` under a comment stating the rule outright: *a status poll
|
|
15
|
+
* must never relaunch the browser or claim a lost handoff is done.* Three lines below that
|
|
16
|
+
* comment sat `if (page.isClosed()) return { solved: true }`, ahead of all three per-reason
|
|
17
|
+
* branches, so it overrode every one of them:
|
|
18
|
+
*
|
|
19
|
+
* - `challenge` — the brain resumed with the CAPTCHA unsolved, and the claim was acted
|
|
20
|
+
* on rather than re-checked: the workflow records `solvedChallengeUrl`, and the apply
|
|
21
|
+
* loop then suppresses captcha re-detection on that page for the rest of the round. A
|
|
22
|
+
* false "solved" muted the detector that would have caught it.
|
|
23
|
+
* - `stuck` — resumed without the human doing the single step the handoff exists for;
|
|
24
|
+
* `humanDone` was never consulted.
|
|
25
|
+
* - `needs_input` — resumed with `value: undefined`, so the workflow skipped the save and
|
|
26
|
+
* the brain re-asked on the next round for a value it had been given.
|
|
27
|
+
*
|
|
28
|
+
* There is nothing to resume ONTO in any of those cases: the page the human was working in
|
|
29
|
+
* no longer exists, and the brain's next action would land on a freshly created blank tab.
|
|
30
|
+
* Staying unsolved lets the wait time out into "not resolved in time", which the cloud maps
|
|
31
|
+
* to `escalated` → the board's "Needs you" column — visible, and retryable by the owner.
|
|
32
|
+
*
|
|
33
|
+
* ── The one exception, and why it is not one
|
|
34
|
+
*
|
|
35
|
+
* `needs_input` is answered BEFORE page liveness is considered, because its answer never
|
|
36
|
+
* involved the page: the value arrives out of band through `browserSubmitInput`, which
|
|
37
|
+
* writes it onto the session. A value the owner typed is a real answer whether or not the
|
|
38
|
+
* tab survived, and discarding it would re-ask for something already in hand.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The answer to a handoff-status poll, or `null` when only the live DOM can give one — a
|
|
42
|
+
* `challenge` on a page that is still open, whose token clears in the page itself.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveHandoffStatus(facts) {
|
|
45
|
+
// Out of band, and therefore unaffected by a page that has gone.
|
|
46
|
+
if (facts.reason === "needs_input")
|
|
47
|
+
return { solved: !!facts.inputValue, challenge: null, value: facts.inputValue };
|
|
48
|
+
// Lost, not done. See the note above — this line is the fix for #641.
|
|
49
|
+
if (facts.pageClosed)
|
|
50
|
+
return { solved: false, challenge: null };
|
|
51
|
+
// A stuck handoff resumes only when the human explicitly clicks Resume — there's
|
|
52
|
+
// nothing to auto-detect.
|
|
53
|
+
if (facts.reason === "stuck")
|
|
54
|
+
return { solved: !!facts.humanDone, challenge: null };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -5,8 +5,11 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
|
|
7
7
|
import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
|
|
8
|
+
import { isTerminalTaskStatus, settleTaskOutcome } from "./apply-outcome.js";
|
|
8
9
|
import { McpRuntime } from "./mcp-runtime.js";
|
|
10
|
+
import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
|
|
9
11
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
12
|
+
import { resolveHandoffStatus } from "./handoff-status.js";
|
|
10
13
|
import { RunnerStore } from "./store.js";
|
|
11
14
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
12
15
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
@@ -169,7 +172,11 @@ export class LocalRunner {
|
|
|
169
172
|
}
|
|
170
173
|
cancelTask(id) {
|
|
171
174
|
const task = this.requireTask(id);
|
|
172
|
-
|
|
175
|
+
// Already finished, whichever way — the old `completed || failed` pair missed `cancelled`, so
|
|
176
|
+
// a second Stop rewrote the timestamps and appended a duplicate event (#636). A run that
|
|
177
|
+
// genuinely completed or failed is NOT relabelled: it ended before the Stop arrived. Off THIS
|
|
178
|
+
// return the cloud mirrored `task.cancelled` carrying `{status:"failed"}` — fixed in the cloud.
|
|
179
|
+
if (isTerminalTaskStatus(task.status))
|
|
173
180
|
return task;
|
|
174
181
|
task.status = "cancelled";
|
|
175
182
|
task.updatedAt = new Date().toISOString();
|
|
@@ -837,6 +844,46 @@ export class LocalRunner {
|
|
|
837
844
|
return false;
|
|
838
845
|
return null;
|
|
839
846
|
}
|
|
847
|
+
/** Evaluate a function ON an element (by snapshot ref) and parse its JSON result. Never throws
|
|
848
|
+
* — a null return means "the page could not be asked", which callers must not read as a yes. */
|
|
849
|
+
async evalJson(mcp, ref, label, fn) {
|
|
850
|
+
const res = await mcp.callTool("browser_evaluate", { element: label, target: ref, function: fn }).catch(() => null);
|
|
851
|
+
if (!res || res.isError)
|
|
852
|
+
return null;
|
|
853
|
+
const txt = mcp.textOf(res);
|
|
854
|
+
const i = txt.indexOf("### Result");
|
|
855
|
+
if (i < 0)
|
|
856
|
+
return null;
|
|
857
|
+
const after = txt.slice(i + "### Result".length).trim();
|
|
858
|
+
const end = after.indexOf("\n###");
|
|
859
|
+
const block = (end >= 0 ? after.slice(0, end) : after).trim();
|
|
860
|
+
try {
|
|
861
|
+
return JSON.parse(block);
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* The commit guard, enforced HERE because this is the process that clicks (#627, #629).
|
|
869
|
+
*
|
|
870
|
+
* The cloud's pre-filter reads a label the model wrote; this one reads the element. For a
|
|
871
|
+
* read-only agent that is a DOM fact — does this control submit a POST form — which no label,
|
|
872
|
+
* in any language, can talk its way past. Returns the refusal to hand back to the brain.
|
|
873
|
+
*/
|
|
874
|
+
async commitRefusal(mcp, page, action, guard) {
|
|
875
|
+
const re = commitLabelRe(guard);
|
|
876
|
+
if (action.action === "click") {
|
|
877
|
+
const ref = (action.ref || "").trim();
|
|
878
|
+
const facts = ref ? await this.evalJson(mcp, ref, action.name || action.role || "control", ELEMENT_PROBE_FN) : null;
|
|
879
|
+
return refuseClick(guard, facts, action.name, re);
|
|
880
|
+
}
|
|
881
|
+
if (action.action === "key") {
|
|
882
|
+
const focus = (await page.evaluate(FOCUS_PROBE_FN).catch(() => null));
|
|
883
|
+
return refuseKey(guard, action.key, focus);
|
|
884
|
+
}
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
840
887
|
/** The snapshot ref the brain must target the element by (standard-tool `target`). */
|
|
841
888
|
refOf(action) {
|
|
842
889
|
const ref = (action.ref || "").trim();
|
|
@@ -943,7 +990,7 @@ export class LocalRunner {
|
|
|
943
990
|
* element by its snapshot ref. A tool-level failure is thrown so the workflow
|
|
944
991
|
* surfaces it to the brain as an `error` (which drives its self-correction).
|
|
945
992
|
*/
|
|
946
|
-
async browserAct(action, resumePath) {
|
|
993
|
+
async browserAct(action, resumePath, guard) {
|
|
947
994
|
const page = await this.getActivePage();
|
|
948
995
|
// Arm résumé auto-attach so a file chooser never blocks the flow (see method).
|
|
949
996
|
// resumePath may be a signed URL (remote runner) or a local path — resolve to
|
|
@@ -970,9 +1017,19 @@ export class LocalRunner {
|
|
|
970
1017
|
title: await active.title().catch(() => ""),
|
|
971
1018
|
challenge: await detectHumanChallenge(active),
|
|
972
1019
|
screenshot: await this.shot(active),
|
|
1020
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
973
1021
|
};
|
|
974
1022
|
}
|
|
975
1023
|
const mcp = await this.getMcp();
|
|
1024
|
+
// BEFORE the tool call, never after: this is the last point at which the action is still
|
|
1025
|
+
// only a proposal. `commitGuard.supported` on every reply is how the cloud learns that this
|
|
1026
|
+
// enforcement exists here at all — measured from the runner's own answer, not guessed from
|
|
1027
|
+
// a version number, because the published CLI upgrades on the field's schedule.
|
|
1028
|
+
if (guard) {
|
|
1029
|
+
const refusal = await this.commitRefusal(mcp, page, action, guard);
|
|
1030
|
+
if (refusal)
|
|
1031
|
+
throw new RunnerInputError(refusal);
|
|
1032
|
+
}
|
|
976
1033
|
const res = await this.callBrowserTool(mcp, action);
|
|
977
1034
|
const text = mcp.textOf(res).trim();
|
|
978
1035
|
// A native page dialog (alert/confirm/beforeunload) puts the standard server
|
|
@@ -994,6 +1051,7 @@ export class LocalRunner {
|
|
|
994
1051
|
challenge: await detectHumanChallenge(settled),
|
|
995
1052
|
feedback: dialogMsg ? `a native dialog was accepted: "${dialogMsg}"` : "a native dialog was accepted",
|
|
996
1053
|
screenshot: await this.shot(settled),
|
|
1054
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
997
1055
|
};
|
|
998
1056
|
}
|
|
999
1057
|
if (res.isError)
|
|
@@ -1027,6 +1085,7 @@ export class LocalRunner {
|
|
|
1027
1085
|
challenge: await detectHumanChallenge(active),
|
|
1028
1086
|
feedback: feedback || undefined,
|
|
1029
1087
|
screenshot: await this.shot(active),
|
|
1088
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
1030
1089
|
};
|
|
1031
1090
|
}
|
|
1032
1091
|
// ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
|
|
@@ -1091,15 +1150,12 @@ export class LocalRunner {
|
|
|
1091
1150
|
if (!session)
|
|
1092
1151
|
return { solved: false, challenge: null };
|
|
1093
1152
|
const page = session.page;
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
// there's nothing to auto-detect.
|
|
1101
|
-
if (session.reason === "stuck")
|
|
1102
|
-
return { solved: !!session.humanDone, challenge: null };
|
|
1153
|
+
// Every answer that needs no live DOM — including a GONE page, which used to report
|
|
1154
|
+
// solved:true and resume past an unsolved challenge (#641) — comes from
|
|
1155
|
+
// handoff-status.ts; null means only an open challenge's own DOM can answer.
|
|
1156
|
+
const offline = resolveHandoffStatus({ reason: session.reason, pageClosed: page.isClosed(), inputValue: session.inputValue, humanDone: session.humanDone });
|
|
1157
|
+
if (offline)
|
|
1158
|
+
return offline;
|
|
1103
1159
|
// A challenge resumes when the token/widget clears OR the human clicks Done —
|
|
1104
1160
|
// custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
|
|
1105
1161
|
// the human's explicit Done is the authority; never strand them.
|
|
@@ -1144,23 +1200,17 @@ export class LocalRunner {
|
|
|
1144
1200
|
}
|
|
1145
1201
|
return { ok: true };
|
|
1146
1202
|
}
|
|
1147
|
-
/** Finalize an agent-driven application
|
|
1203
|
+
/** Finalize an agent-driven application — the outcome→status table lives in apply-outcome.ts. */
|
|
1148
1204
|
async browserComplete(taskId, outcome, detail) {
|
|
1149
1205
|
await this.endTakeover(taskId).catch(() => undefined);
|
|
1150
1206
|
const task = this.store.getTask(taskId);
|
|
1151
1207
|
if (task) {
|
|
1152
|
-
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
task.status = outcome === "blocked" ? "blocked" : success ? "completed" : "failed";
|
|
1157
|
-
task.output = { outcome, detail };
|
|
1158
|
-
if (!success)
|
|
1159
|
-
task.error = detail || outcome;
|
|
1160
|
-
task.updatedAt = new Date().toISOString();
|
|
1161
|
-
task.completedAt = task.updatedAt;
|
|
1208
|
+
// A TOTAL table, not a chain of string comparisons: `cancelled` matched none of
|
|
1209
|
+
// them and fell through to `failed`, so a run the owner stopped was filed as an
|
|
1210
|
+
// error with a Retry button on it (#636). See apply-outcome.ts.
|
|
1211
|
+
const { event } = settleTaskOutcome(task, outcome, detail);
|
|
1162
1212
|
this.store.putTask(task);
|
|
1163
|
-
this.addTaskEvent(task,
|
|
1213
|
+
this.addTaskEvent(task, event, detail || `Application ${outcome}`, { outcome, detail });
|
|
1164
1214
|
}
|
|
1165
1215
|
await this.closeStalePages().catch(() => undefined);
|
|
1166
1216
|
return { ok: true };
|
|
@@ -90,8 +90,12 @@ async function route(runner, req, res) {
|
|
|
90
90
|
return json(res, 200, await runner.browserSnapshot(b.taskId));
|
|
91
91
|
}
|
|
92
92
|
if (req.method === "POST" && path === "/browser/act") {
|
|
93
|
+
// `guard` is the commit policy for THIS run (#627, #629) — a rehearsal or a read-only
|
|
94
|
+
// agent. It travels with every action rather than being registered once, because the
|
|
95
|
+
// runner serves many instances at once and a per-connection mode would be a second piece
|
|
96
|
+
// of state to get wrong.
|
|
93
97
|
const body = await readJson(req);
|
|
94
|
-
return json(res, 200, await runner.browserAct(body, body.resumePath));
|
|
98
|
+
return json(res, 200, await runner.browserAct(body, body.resumePath, body.guard));
|
|
95
99
|
}
|
|
96
100
|
if (req.method === "POST" && path === "/browser/event") {
|
|
97
101
|
const b = await readJson(req);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
export async function startTestJobServer(port = 0) {
|
|
3
3
|
const submissions = [];
|
|
4
|
+
const searches = [];
|
|
4
5
|
const server = createServer(async (req, res) => {
|
|
5
6
|
try {
|
|
6
|
-
await route(req, res, submissions);
|
|
7
|
+
await route(req, res, submissions, searches);
|
|
7
8
|
}
|
|
8
9
|
catch (error) {
|
|
9
10
|
html(res, 500, `<h1>Server Error</h1><pre>${escapeHtml(String(error))}</pre>`);
|
|
@@ -17,7 +18,10 @@ export async function startTestJobServer(port = 0) {
|
|
|
17
18
|
return {
|
|
18
19
|
url,
|
|
19
20
|
jobUrl: `${url}/jobs/software-engineer`,
|
|
21
|
+
quickApplyUrl: `${url}/jobs/quick-apply`,
|
|
22
|
+
searchUrl: `${url}/search`,
|
|
20
23
|
submissions,
|
|
24
|
+
searches,
|
|
21
25
|
async close() {
|
|
22
26
|
await new Promise((resolve, reject) => {
|
|
23
27
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
@@ -36,7 +40,7 @@ export async function startTestJobServer(port = 0) {
|
|
|
36
40
|
},
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
|
-
async function route(req, res, submissions) {
|
|
43
|
+
async function route(req, res, submissions, searches) {
|
|
40
44
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
41
45
|
if (req.method === "GET" && url.pathname === "/") {
|
|
42
46
|
redirect(res, "/jobs/software-engineer");
|
|
@@ -51,6 +55,17 @@ async function route(req, res, submissions) {
|
|
|
51
55
|
}));
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
58
|
+
if (req.method === "GET" && url.pathname === "/jobs/quick-apply") {
|
|
59
|
+
html(res, 200, quickApplyPage());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === "GET" && url.pathname === "/search") {
|
|
63
|
+
const q = url.searchParams.get("q");
|
|
64
|
+
if (q !== null)
|
|
65
|
+
searches.push(q);
|
|
66
|
+
html(res, 200, searchPage(q));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
54
69
|
if (req.method === "POST" && url.pathname === "/apply") {
|
|
55
70
|
const submission = await parseApplication(req);
|
|
56
71
|
submissions.unshift(submission);
|
|
@@ -125,6 +140,33 @@ function jobPage(opts = {}) {
|
|
|
125
140
|
</main>
|
|
126
141
|
`);
|
|
127
142
|
}
|
|
143
|
+
/** One click = one real application. No `required` attribute anywhere, so nothing but the guard
|
|
144
|
+
* can stop the POST — a fixture where HTML validation did the stopping would prove nothing. */
|
|
145
|
+
function quickApplyPage() {
|
|
146
|
+
return page("Postuler en 1 clic", `
|
|
147
|
+
<main>
|
|
148
|
+
<section class="job"><h1>Ingénieur logiciel</h1><p class="company">Fixture Labs</p></section>
|
|
149
|
+
<form action="/apply" method="post" enctype="multipart/form-data" class="application-form">
|
|
150
|
+
<input type="hidden" name="fullName" value="Sergey Ivochkin" />
|
|
151
|
+
<input type="hidden" name="email" value="sergey@example.com" />
|
|
152
|
+
<label>Recherche <input name="coverNote" aria-label="Recherche" /></label>
|
|
153
|
+
<button type="submit">Envoyer ma candidature</button>
|
|
154
|
+
</form>
|
|
155
|
+
</main>
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
/** A GET search form: submitting it only READS. */
|
|
159
|
+
function searchPage(q) {
|
|
160
|
+
return page("Recherche", `
|
|
161
|
+
<main>
|
|
162
|
+
<form action="/search" method="get">
|
|
163
|
+
<label>Search <input name="q" aria-label="Search" /></label>
|
|
164
|
+
<button type="submit">Search</button>
|
|
165
|
+
</form>
|
|
166
|
+
<p id="result">${q === null ? "no query" : `searched: ${escapeHtml(q)}`}</p>
|
|
167
|
+
</main>
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
128
170
|
function successPage(submission) {
|
|
129
171
|
return page("Application Received", `
|
|
130
172
|
<main>
|