toolroll 0.9.21 → 0.9.23
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/CHANGELOG.md +12 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/flow-send.js +10 -1
- package/dist/scout-browser.d.ts +48 -0
- package/dist/scout-browser.js +228 -0
- package/dist/scout.d.ts +11 -5
- package/dist/scout.js +60 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.23 — 2026-10-04
|
|
4
|
+
|
|
5
|
+
- After a research step, a flow's message to you now carries that research's
|
|
6
|
+
screenshots. Before, it only looked for a build's.
|
|
7
|
+
|
|
8
|
+
## 0.9.22 — 2026-10-04
|
|
9
|
+
|
|
10
|
+
- **Research takes screenshots again.** The research step's browser now runs
|
|
11
|
+
outside the agent's sandbox with Chrome's own sandbox on, reached on a
|
|
12
|
+
private local port. It still opens public pages only. A short check before
|
|
13
|
+
research starts says once, in plain words, if the browser can't start.
|
|
14
|
+
|
|
3
15
|
## 0.9.21 — 2026-10-03
|
|
4
16
|
|
|
5
17
|
- **Flows send you what was done.** A new "Send to me" step sends the last
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,20 +125,20 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
-
error?: never;
|
|
129
|
-
version?: never;
|
|
130
128
|
active: boolean;
|
|
131
129
|
phase: string;
|
|
132
130
|
detail: string;
|
|
133
131
|
running: boolean;
|
|
134
132
|
canResume: boolean;
|
|
135
133
|
canCancel: boolean;
|
|
134
|
+
error?: never;
|
|
136
135
|
automaticRecovery?: never;
|
|
137
136
|
recoveryAttempts?: never;
|
|
138
137
|
recoveryRepairs?: never;
|
|
139
138
|
wasRunning?: never;
|
|
140
139
|
backupPath?: never;
|
|
141
140
|
updateId?: never;
|
|
141
|
+
version?: never;
|
|
142
142
|
buildId?: never;
|
|
143
143
|
workDir?: never;
|
|
144
144
|
} | {
|
package/dist/flow-send.js
CHANGED
|
@@ -55,6 +55,14 @@ function resultOf(store, taskId, now, evidenceRoot) {
|
|
|
55
55
|
shots: receipt !== null && store.artifactsFor(receipt.runId).some(one => one.kind === "screenshot") ? { taskId: receipt.taskId, run: receipt.runId } : null,
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
+
/** A research task's latest delivered report, when its run saved screenshots: what that step produced to show. */
|
|
59
|
+
function reportShotsOf(store, taskId) {
|
|
60
|
+
const ref = store.lookupRef(taskId);
|
|
61
|
+
if (ref === null)
|
|
62
|
+
return null;
|
|
63
|
+
const run = store.runsFor(ref.id).find(one => one.finishedAt !== null && one.role === "scout");
|
|
64
|
+
return run !== undefined && run.outcome === "built" && store.artifactsFor(run.id).some(one => one.kind === "screenshot") ? { taskId, run: run.id } : null;
|
|
65
|
+
}
|
|
58
66
|
/** What a visit sends its person, worked out from the card as it is now. */
|
|
59
67
|
export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
60
68
|
const before = stepBefore(store, definition, card);
|
|
@@ -90,7 +98,8 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
|
|
|
90
98
|
const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
|
|
91
99
|
return {
|
|
92
100
|
title: cut(plain(before === null ? card.title : `${card.title} · ${before.title}`), 200), ...(before === null ? {} : { from: before.title }),
|
|
93
|
-
|
|
101
|
+
// After research, its own screenshots are what that step produced; otherwise the build's.
|
|
102
|
+
summary: plain(lines.join("\n\n")), links, shots: (report === null ? null : reportShotsOf(store, report)) ?? result?.shots ?? null,
|
|
94
103
|
...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
|
|
95
104
|
};
|
|
96
105
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type BrowserLaunch = {
|
|
2
|
+
command: string;
|
|
3
|
+
args: readonly string[];
|
|
4
|
+
};
|
|
5
|
+
export type ScoutBrowserServer = {
|
|
6
|
+
url: string;
|
|
7
|
+
close: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type BrowserStart = {
|
|
10
|
+
ok: true;
|
|
11
|
+
server: ScoutBrowserServer;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export type BrowserStarter = (launch: BrowserLaunch, options: BrowserOptions) => Promise<BrowserStart>;
|
|
17
|
+
export type BrowserOptions = {
|
|
18
|
+
/** The browser tool's environment: the scout's own, its proxy settings included. */
|
|
19
|
+
env: Record<string, string | undefined>;
|
|
20
|
+
/** Where it saves screenshots (its `--output-dir`), also its working folder. */
|
|
21
|
+
folder: string;
|
|
22
|
+
/** The whole start and check, from spawn to screenshot (review 828: at most 60 seconds). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
};
|
|
25
|
+
/** The public page the check opens through the scout's proxy. */
|
|
26
|
+
export declare const PREFLIGHT_PAGE = "https://example.com/";
|
|
27
|
+
/** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
|
|
28
|
+
export declare function starterFor(agentInjected: boolean): BrowserStarter;
|
|
29
|
+
/** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
|
|
30
|
+
export declare function startScoutBrowser(launch: BrowserLaunch, options: BrowserOptions): Promise<BrowserStart>;
|
|
31
|
+
export type McpSession = {
|
|
32
|
+
start: () => Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
}>;
|
|
38
|
+
call: (tool: string, input: Record<string, unknown>) => Promise<{
|
|
39
|
+
ok: true;
|
|
40
|
+
result: Record<string, unknown>;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: string;
|
|
44
|
+
}>;
|
|
45
|
+
close: () => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
/** A client of the browser's HTTP transport, as the scout's own is: every step answers by `deadline`, or says why not. */
|
|
48
|
+
export declare function openSession(url: string, deadline: number, late: string): McpSession;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scout's browser (run 2356, review 828). Chrome's own sandbox can't start inside the agent fence (nested
|
|
3
|
+
* Seatbelt: every page crashed, even example.com), and switching that sandbox off is not an option. So the browser
|
|
4
|
+
* tool runs beside the agent instead of under it: the worker starts the pinned Playwright MCP itself, outside the
|
|
5
|
+
* fence with Chrome's sandbox on, serving its HTTP transport on a random loopback port, and the fenced agent gets
|
|
6
|
+
* only that URL. Every page still goes through the scout's public-web-only proxy, and screenshots land in the run's
|
|
7
|
+
* image folder. Before research begins the browser is checked once, through that proxy, on a public page, with one
|
|
8
|
+
* time limit for the whole check; a browser that can't start is said in one line and the scout researches
|
|
9
|
+
* without it.
|
|
10
|
+
*
|
|
11
|
+
* Playwright MCP 0.0.82 has no per-client token for its HTTP transport; it answers only on 127.0.0.1, only for
|
|
12
|
+
* that host name (its own host check), and only while this run's scout does.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { request as httpRequest } from "node:http";
|
|
16
|
+
import { connect, createServer } from "node:net";
|
|
17
|
+
import { readdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/** The public page the check opens through the scout's proxy. */
|
|
20
|
+
export const PREFLIGHT_PAGE = "https://example.com/";
|
|
21
|
+
const PREFLIGHT_TIMEOUT_MS = 60_000;
|
|
22
|
+
const CLOSE_GRACE_MS = 5_000;
|
|
23
|
+
const IMAGE = /\.(png|jpe?g)$/i;
|
|
24
|
+
/** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
|
|
25
|
+
export function starterFor(agentInjected) {
|
|
26
|
+
return agentInjected ? unstarted : startScoutBrowser;
|
|
27
|
+
}
|
|
28
|
+
const unstarted = async () => ({ ok: true, server: { url: "http://127.0.0.1:9/mcp", close: async () => undefined } });
|
|
29
|
+
/** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
|
|
30
|
+
export async function startScoutBrowser(launch, options) {
|
|
31
|
+
const timeoutMs = options.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
const late = `the browser did not start within ${Math.round(timeoutMs / 1000)} seconds`;
|
|
34
|
+
const served = await serveBrowser(launch, options, deadline, late);
|
|
35
|
+
if (!served.ok)
|
|
36
|
+
return served;
|
|
37
|
+
const before = new Set(entries(options.folder));
|
|
38
|
+
const session = openSession(served.server.url, deadline, late);
|
|
39
|
+
try {
|
|
40
|
+
let checked = await preflight(session);
|
|
41
|
+
if (checked.ok && !entries(options.folder).some(name => !before.has(name) && IMAGE.test(name)))
|
|
42
|
+
checked = { ok: false, reason: "the browser saved no screenshot" };
|
|
43
|
+
if (!checked.ok) {
|
|
44
|
+
await served.server.close();
|
|
45
|
+
return checked;
|
|
46
|
+
}
|
|
47
|
+
return served;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await session.close();
|
|
51
|
+
// The check's own screenshot and page notes are not the scout's.
|
|
52
|
+
for (const name of entries(options.folder))
|
|
53
|
+
if (!before.has(name))
|
|
54
|
+
rmSync(join(options.folder, name), { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function preflight(session) {
|
|
58
|
+
const started = await session.start();
|
|
59
|
+
if (!started.ok)
|
|
60
|
+
return started;
|
|
61
|
+
for (const [tool, input] of [["browser_navigate", { url: PREFLIGHT_PAGE }], ["browser_take_screenshot", { type: "png" }]]) {
|
|
62
|
+
const done = await session.call(tool, input);
|
|
63
|
+
if (!done.ok)
|
|
64
|
+
return done;
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
/** Spawn the tool with its HTTP transport on a free 127.0.0.1 port and wait until it answers there. */
|
|
69
|
+
async function serveBrowser(launch, options, deadline, late) {
|
|
70
|
+
const port = await freePort();
|
|
71
|
+
const child = spawn(launch.command, [...launch.args, "--host", "127.0.0.1", "--port", String(port), "--allowed-hosts", `127.0.0.1:${port}`], {
|
|
72
|
+
cwd: options.folder,
|
|
73
|
+
env: options.env,
|
|
74
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
75
|
+
detached: process.platform !== "win32",
|
|
76
|
+
});
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let stopped = null;
|
|
79
|
+
child.on("error", error => { stopped ??= `the browser tool could not run (${error.message})`; });
|
|
80
|
+
child.on("exit", (code, sig) => { stopped ??= `the browser tool stopped (${sig ?? `exit ${code}`})${lastLine(stderr) === "" ? "" : `: ${lastLine(stderr)}`}`; });
|
|
81
|
+
child.stderr.on("data", chunk => { stderr = (stderr + String(chunk)).slice(-4_000); });
|
|
82
|
+
const close = async () => {
|
|
83
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
84
|
+
const exited = new Promise(done => child.once("exit", () => done()));
|
|
85
|
+
signal(child.pid, "SIGTERM");
|
|
86
|
+
await Promise.race([exited, new Promise(done => setTimeout(done, CLOSE_GRACE_MS).unref())]);
|
|
87
|
+
}
|
|
88
|
+
// Its browser and helpers go with it.
|
|
89
|
+
signal(child.pid, "SIGKILL");
|
|
90
|
+
};
|
|
91
|
+
// First runs download the pinned tool; it listens once it is ready.
|
|
92
|
+
while (stopped === null && Date.now() < deadline && !(await answers(port)))
|
|
93
|
+
await new Promise(done => setTimeout(done, 250));
|
|
94
|
+
if (stopped !== null || Date.now() >= deadline) {
|
|
95
|
+
const reason = stopped ?? late;
|
|
96
|
+
await close();
|
|
97
|
+
return { ok: false, reason };
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, server: { url: `http://127.0.0.1:${port}/mcp`, close } };
|
|
100
|
+
}
|
|
101
|
+
function freePort() {
|
|
102
|
+
return new Promise((done, fail) => {
|
|
103
|
+
const probe = createServer();
|
|
104
|
+
probe.once("error", fail);
|
|
105
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
106
|
+
const address = probe.address();
|
|
107
|
+
probe.close(() => (typeof address === "object" && address !== null ? done(address.port) : fail(new Error("no free port"))));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function answers(port) {
|
|
112
|
+
return new Promise(done => {
|
|
113
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
114
|
+
socket.setTimeout(1_000);
|
|
115
|
+
socket.once("connect", () => { socket.destroy(); done(true); });
|
|
116
|
+
socket.once("error", () => done(false));
|
|
117
|
+
socket.once("timeout", () => { socket.destroy(); done(false); });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function signal(pid, name) {
|
|
121
|
+
if (pid === undefined)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
process.kill(process.platform === "win32" ? pid : -pid, name);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already gone */ }
|
|
127
|
+
}
|
|
128
|
+
function entries(folder) {
|
|
129
|
+
try {
|
|
130
|
+
return readdirSync(folder);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A client of the browser's HTTP transport, as the scout's own is: every step answers by `deadline`, or says why not. */
|
|
137
|
+
export function openSession(url, deadline, late) {
|
|
138
|
+
let session = null;
|
|
139
|
+
let next = 0;
|
|
140
|
+
const post = (body, method = "POST") => new Promise(done => {
|
|
141
|
+
const remaining = deadline - Date.now();
|
|
142
|
+
if (remaining <= 0)
|
|
143
|
+
return done({ ok: false, reason: late });
|
|
144
|
+
const headers = { accept: "application/json, text/event-stream", "content-type": "application/json" };
|
|
145
|
+
if (session !== null)
|
|
146
|
+
headers["mcp-session-id"] = session;
|
|
147
|
+
const sent = httpRequest(url, { method, headers }, answer => {
|
|
148
|
+
let text = "";
|
|
149
|
+
answer.on("data", chunk => { text += String(chunk); });
|
|
150
|
+
answer.on("end", () => { clearTimeout(timer); done({ ok: true, status: answer.statusCode ?? 0, headers: answer.headers, text }); });
|
|
151
|
+
answer.on("error", error => { clearTimeout(timer); done({ ok: false, reason: `the browser stopped answering (${error.message})` }); });
|
|
152
|
+
});
|
|
153
|
+
const timer = setTimeout(() => { sent.destroy(); done({ ok: false, reason: late }); }, remaining);
|
|
154
|
+
sent.on("error", error => { clearTimeout(timer); done({ ok: false, reason: `the browser stopped answering (${error.message})` }); });
|
|
155
|
+
sent.end(method === "POST" ? JSON.stringify(body) : undefined);
|
|
156
|
+
});
|
|
157
|
+
const request = async (method, params) => {
|
|
158
|
+
const id = ++next;
|
|
159
|
+
const sent = await post({ jsonrpc: "2.0", id, method, params });
|
|
160
|
+
if (!sent.ok)
|
|
161
|
+
return sent;
|
|
162
|
+
const message = messages(sent.text).find(one => one["id"] === id);
|
|
163
|
+
if (message === undefined)
|
|
164
|
+
return { ok: false, reason: `the browser tool answered ${sent.status}${sent.text.trim() === "" ? "" : `: ${oneLine(sent.text)}`}` };
|
|
165
|
+
const header = sent.headers["mcp-session-id"];
|
|
166
|
+
if (typeof header === "string")
|
|
167
|
+
session = header;
|
|
168
|
+
return { ok: true, message };
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
start: async () => {
|
|
172
|
+
const hello = await request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "toolroll-scout-preflight", version: "1" } });
|
|
173
|
+
if (!hello.ok)
|
|
174
|
+
return hello;
|
|
175
|
+
if (hello.message["error"] !== undefined)
|
|
176
|
+
return { ok: false, reason: `the browser tool refused to start: ${errorText(hello.message)}` };
|
|
177
|
+
const ready = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
178
|
+
return ready.ok ? { ok: true } : ready;
|
|
179
|
+
},
|
|
180
|
+
call: async (tool, input) => {
|
|
181
|
+
const answer = await request("tools/call", { name: tool, arguments: input });
|
|
182
|
+
if (!answer.ok)
|
|
183
|
+
return answer;
|
|
184
|
+
const failed = toolFailure(answer.message);
|
|
185
|
+
return failed === null ? { ok: true, result: (answer.message["result"] ?? {}) } : { ok: false, reason: failed };
|
|
186
|
+
},
|
|
187
|
+
close: async () => {
|
|
188
|
+
// Ending the session closes its browser context.
|
|
189
|
+
if (session !== null)
|
|
190
|
+
await post({}, "DELETE");
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** The JSON-RPC messages in an answer: a plain JSON body, or the data lines of an event stream. */
|
|
195
|
+
function messages(text) {
|
|
196
|
+
const bodies = text.trimStart().startsWith("{") ? [text] : text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5));
|
|
197
|
+
return bodies.flatMap(body => {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(body);
|
|
200
|
+
return typeof parsed === "object" && parsed !== null ? [parsed] : [];
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Why a tool call failed, in one line, or null when it worked. */
|
|
208
|
+
function toolFailure(answer) {
|
|
209
|
+
if (answer["error"] !== undefined)
|
|
210
|
+
return errorText(answer);
|
|
211
|
+
const result = answer["result"];
|
|
212
|
+
if (result?.isError !== true)
|
|
213
|
+
return null;
|
|
214
|
+
const text = result.content?.map(one => (typeof one.text === "string" ? one.text : "")).join("\n") ?? "";
|
|
215
|
+
const line = text.split("\n").map(one => one.replace(/^#+\s*Error\s*$/i, "").replace(/^Error:\s*/, "").replace(/^browserBackend\.callTool:\s*/, "").trim()).find(one => one !== "");
|
|
216
|
+
return oneLine(line ?? "unknown error");
|
|
217
|
+
}
|
|
218
|
+
function errorText(answer) {
|
|
219
|
+
const error = answer["error"];
|
|
220
|
+
return oneLine(typeof error?.message === "string" ? error.message : "unknown error");
|
|
221
|
+
}
|
|
222
|
+
function lastLine(text) {
|
|
223
|
+
return oneLine(text.trim().split("\n").at(-1) ?? "");
|
|
224
|
+
}
|
|
225
|
+
function oneLine(text) {
|
|
226
|
+
// eslint-disable-next-line no-control-regex
|
|
227
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 200);
|
|
228
|
+
}
|
package/dist/scout.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ParsedDecision, type Problem } from "./decision.js";
|
|
|
3
3
|
import { type ParsedReport, type ReportProblem } from "./scout-report.js";
|
|
4
4
|
import type { Runner } from "./builder.js";
|
|
5
5
|
import { type ToolSpec } from "./project-tools.js";
|
|
6
|
+
import * as browserCheck from "./scout-browser.js";
|
|
6
7
|
/** The scout's own headless browser: the pinned Playwright tool, saving its
|
|
7
8
|
* screenshots straight into the run's image folder. */
|
|
8
9
|
export declare const SCOUT_BROWSER = "toolroll-browser";
|
|
@@ -15,12 +16,17 @@ export declare const SCOUT_BROWSER = "toolroll-browser";
|
|
|
15
16
|
*/
|
|
16
17
|
export declare const SCOUT_ALLOWED_TOOLS: readonly string[];
|
|
17
18
|
/** The scout's browser launch, or null when this install has no Playwright entry (the scout then researches without
|
|
18
|
-
* screenshots).
|
|
19
|
-
*
|
|
20
|
-
|
|
19
|
+
* screenshots). The worker runs it outside the agent fence with Chrome's own sandbox on (run 2356, review 828:
|
|
20
|
+
* scout-browser.ts). Every request it makes goes through `proxy` (scout-net.ts), which lets only public pages and the
|
|
21
|
+
* project's own demo through; loopback is proxied too, never bypassed, and file: stays blocked. Screenshots are saved
|
|
22
|
+
* in `imageFolder`. */
|
|
23
|
+
export declare function scoutBrowserLaunch(tool: Pick<ToolSpec, "command" | "args"> | null, imageFolder: string, proxy: string): browserCheck.BrowserLaunch | null;
|
|
24
|
+
/** What the fenced scout gets: the running browser's loopback address, nothing it could launch itself. */
|
|
25
|
+
export declare function scoutBrowser(url: string): Record<string, unknown>;
|
|
21
26
|
/** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
|
|
22
|
-
* the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
|
|
23
|
-
|
|
27
|
+
* the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
|
|
28
|
+
* The one direct address is its own browser's port, when it has one (review 828): the proxy refuses loopback. */
|
|
29
|
+
export declare function scoutProxyEnv(proxy: string, browserUrl?: string | null): Record<string, string>;
|
|
24
30
|
export type ScoutRequest = {
|
|
25
31
|
/** v105: what's left of a monthly budget this API-key work counts toward (the CLI's own cap), when one does. */
|
|
26
32
|
maxBudgetUsd?: number;
|
package/dist/scout.js
CHANGED
|
@@ -20,7 +20,6 @@ import { currentClaim, heartbeat } from "./claim.js";
|
|
|
20
20
|
import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
21
21
|
import { parseDecision } from "./decision.js";
|
|
22
22
|
import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
|
|
23
|
-
import { invokeAgent } from "./invoke.js";
|
|
24
23
|
import { TOKEN_ENVS as TELEGRAM_TOKEN_ENVS } from "./telegram.js";
|
|
25
24
|
import { evidenceRoot, mailboxName, quarantineMailboxes, readMailbox, reportFileName, SCREENSHOT_BYTE_CAP, storeEvidence, validateScreenshotBytes, writeEvidenceFile, } from "./evidence.js";
|
|
26
25
|
import { MARKER as LEASE_MARKER } from "./worktree.js";
|
|
@@ -30,6 +29,8 @@ import { redactSecretLines, scanForSecrets } from "./evidence.js";
|
|
|
30
29
|
import { CLAUDE_LIMITS } from "./scope.js";
|
|
31
30
|
import { catalogTool } from "./project-tools.js";
|
|
32
31
|
import { startScoutProxy } from "./scout-net.js";
|
|
32
|
+
import * as browserCheck from "./scout-browser.js";
|
|
33
|
+
import { invokeAgent } from "./invoke.js";
|
|
33
34
|
const GIT = "git";
|
|
34
35
|
const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
|
|
35
36
|
const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
|
|
@@ -49,19 +50,27 @@ const SCOUT_BROWSER_TOOLS = ["browser_navigate", "browser_navigate_back", "brows
|
|
|
49
50
|
export const SCOUT_ALLOWED_TOOLS = ["WebSearch", "WebFetch", ...SCOUT_BROWSER_TOOLS.map(tool => `mcp__${SCOUT_BROWSER}__${tool}`)];
|
|
50
51
|
const SCOUT_RESEARCH_TOOLS = ["WebSearch", "WebFetch"];
|
|
51
52
|
/** The scout's browser launch, or null when this install has no Playwright entry (the scout then researches without
|
|
52
|
-
* screenshots).
|
|
53
|
-
*
|
|
54
|
-
|
|
53
|
+
* screenshots). The worker runs it outside the agent fence with Chrome's own sandbox on (run 2356, review 828:
|
|
54
|
+
* scout-browser.ts). Every request it makes goes through `proxy` (scout-net.ts), which lets only public pages and the
|
|
55
|
+
* project's own demo through; loopback is proxied too, never bypassed, and file: stays blocked. Screenshots are saved
|
|
56
|
+
* in `imageFolder`. */
|
|
57
|
+
export function scoutBrowserLaunch(tool, imageFolder, proxy) {
|
|
55
58
|
if (tool === null || tool.command === null)
|
|
56
59
|
return null;
|
|
57
|
-
return {
|
|
60
|
+
return { command: tool.command, args: [...tool.args, "--isolated", "--output-dir", imageFolder, "--proxy-server", proxy, "--proxy-bypass", "<-loopback>"] };
|
|
61
|
+
}
|
|
62
|
+
/** What the fenced scout gets: the running browser's loopback address, nothing it could launch itself. */
|
|
63
|
+
export function scoutBrowser(url) {
|
|
64
|
+
return { [SCOUT_BROWSER]: { type: "http", url } };
|
|
58
65
|
}
|
|
59
66
|
/** What would let the scout's own requests skip its proxy: never passed on while one runs. */
|
|
60
67
|
const PROXY_BYPASS_ENV = ["NO_PROXY", "no_proxy", "ALL_PROXY", "all_proxy"];
|
|
61
68
|
/** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
|
|
62
|
-
* the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
* the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
|
|
70
|
+
* The one direct address is its own browser's port, when it has one (review 828): the proxy refuses loopback. */
|
|
71
|
+
export function scoutProxyEnv(proxy, browserUrl = null) {
|
|
72
|
+
const direct = browserUrl === null ? {} : { NO_PROXY: new URL(browserUrl).host, no_proxy: new URL(browserUrl).host };
|
|
73
|
+
return { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy, ...direct };
|
|
65
74
|
}
|
|
66
75
|
/** One line of untrusted text made inert for the brief (audit IV-4). */
|
|
67
76
|
function inert(text, cap = 300) {
|
|
@@ -101,15 +110,21 @@ function scoutBrief(title, goal, outOfScope, mailbox, reportFile, answers, struc
|
|
|
101
110
|
: ["The web is unavailable in this run: research from the repository alone."]),
|
|
102
111
|
...(browser === null
|
|
103
112
|
? ["No browser is available for screenshots in this run, so leave images empty."]
|
|
104
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
: "problem" in browser
|
|
114
|
+
? [
|
|
115
|
+
`The browser couldn't start (${inert(browser.problem, 200)}), so this run has no screenshots:`,
|
|
116
|
+
"leave images empty. The report already says so; don't repeat it.",
|
|
117
|
+
]
|
|
118
|
+
: [
|
|
119
|
+
"You may take screenshots (PNG or JPEG) of public web pages you actually",
|
|
120
|
+
`visited${browser.demoUrl === null ? "" : `, and of this project's own UI at \`${inert(browser.demoUrl, 500)}\``}. The browser`,
|
|
121
|
+
"opens public web pages only. Open the page with the",
|
|
122
|
+
`\`${SCOUT_BROWSER}\` browser, then save its screenshot by its full path in`,
|
|
123
|
+
`this folder outside the repository, such as \`${join(browser.folder, "home.png")}\`,`,
|
|
124
|
+
"and name it by its plain file name (home.png) in the report. A screenshot",
|
|
125
|
+
`saved anywhere else is not kept. At most ${REPORT_LIMITS.images}, each under ${SCREENSHOT_BYTE_CAP / (1024 * 1024)} MB.`,
|
|
126
|
+
"Never screenshot a page you did not visit.",
|
|
127
|
+
]),
|
|
113
128
|
answeredBlock,
|
|
114
129
|
...(structured
|
|
115
130
|
? [
|
|
@@ -253,13 +268,28 @@ async function scoutWith(store, request, outputDir) {
|
|
|
253
268
|
const browserTool = request.browserTool === undefined ? catalogTool("playwright") : request.browserTool;
|
|
254
269
|
const demoUrl = request.demoUrl ?? null;
|
|
255
270
|
let proxy = null;
|
|
271
|
+
let browserServer = null;
|
|
272
|
+
let browserProblem = null;
|
|
256
273
|
let invoked;
|
|
257
274
|
try {
|
|
258
275
|
proxy = await startScoutProxy({ demoUrl }).catch(() => null);
|
|
259
|
-
const
|
|
276
|
+
const launch = proxy === null || !structured ? null : scoutBrowserLaunch(browserTool, imageFolder, proxy.url);
|
|
277
|
+
// The preflight (run 2356, review 828): the worker starts the browser outside the fence and opens a public page
|
|
278
|
+
// through the proxy before research begins. One that can't start is said once, in the brief and the report, and
|
|
279
|
+
// the scout researches without it.
|
|
280
|
+
const started = launch === null || proxy === null ? null : await browserCheck.starterFor(agent !== undefined)(launch, {
|
|
281
|
+
env: { ...withoutEnv(process.env, [...AGENT_ENV_DENYLIST, ...PROXY_BYPASS_ENV]), ...scoutProxyEnv(proxy.url) },
|
|
282
|
+
folder: imageFolder,
|
|
283
|
+
}).catch((error) => ({ ok: false, reason: error instanceof Error ? error.message : String(error) }));
|
|
284
|
+
if (started !== null && started.ok)
|
|
285
|
+
browserServer = started.server;
|
|
286
|
+
browserProblem = started !== null && !started.ok ? started.reason : null;
|
|
287
|
+
const browser = browserServer === null ? null : scoutBrowser(browserServer.url);
|
|
288
|
+
const proxyEnv = proxy === null ? {} : scoutProxyEnv(proxy.url, browserServer?.url ?? null);
|
|
289
|
+
const briefBrowser = browserProblem !== null ? { problem: browserProblem } : browser === null ? null : { folder: imageFolder, demoUrl };
|
|
260
290
|
invoked = await invokeAgent(store, request.runId, { provider: request.provider ?? "claude", model: request.model ?? null }, {
|
|
261
291
|
phase: "plan",
|
|
262
|
-
brief: projectSkillContext + scoutBrief(request.taskTitle, request.goal, request.outOfScope, mailbox, reportFile, request.answers ?? [], structured,
|
|
292
|
+
brief: projectSkillContext + scoutBrief(request.taskTitle, request.goal, request.outOfScope, mailbox, reportFile, request.answers ?? [], structured, briefBrowser, proxy !== null),
|
|
263
293
|
maxTurns,
|
|
264
294
|
// Read-only by policy AND by check: `dontAsk` with only research and
|
|
265
295
|
// screenshot tools allowed is the permission posture (plan mode
|
|
@@ -275,8 +305,8 @@ async function scoutWith(store, request, outputDir) {
|
|
|
275
305
|
}, {
|
|
276
306
|
cwd: worktree,
|
|
277
307
|
idleTimeoutMs: timeoutMs,
|
|
278
|
-
omitEnv: [...AGENT_ENV_DENYLIST, ...(proxy === null ? [] : PROXY_BYPASS_ENV)],
|
|
279
|
-
...(proxy === null ? {} : { env:
|
|
308
|
+
omitEnv: [...AGENT_ENV_DENYLIST, ...(proxy === null ? [] : PROXY_BYPASS_ENV.filter(name => !(name in proxyEnv)))],
|
|
309
|
+
...(proxy === null ? {} : { env: proxyEnv }),
|
|
280
310
|
...(agent === undefined ? {} : { runner: agent }),
|
|
281
311
|
...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
|
|
282
312
|
clock,
|
|
@@ -286,6 +316,7 @@ async function scoutWith(store, request, outputDir) {
|
|
|
286
316
|
finally {
|
|
287
317
|
if (pulseTimer !== undefined)
|
|
288
318
|
clearInterval(pulseTimer);
|
|
319
|
+
await browserServer?.close();
|
|
289
320
|
await proxy?.close();
|
|
290
321
|
liveLog?.close();
|
|
291
322
|
}
|
|
@@ -477,7 +508,8 @@ async function scoutWith(store, request, outputDir) {
|
|
|
477
508
|
kept.push({ file, caption: image.caption, url, sha256: createHash("sha256").update(checked.bytes).digest("hex"), artifact });
|
|
478
509
|
}
|
|
479
510
|
const files = new Set(kept.map(one => one.file));
|
|
480
|
-
const note =
|
|
511
|
+
const note = (browserProblem === null ? "" : `\n\n_No screenshots: the browser couldn't start (${browserProblem})._`) +
|
|
512
|
+
(refused.length === 0 ? "" : `\n\n_Screenshots left out: ${refused.join("; ")}._`);
|
|
481
513
|
const document = note !== "" && Buffer.byteLength(report.report + note, "utf8") <= REPORT_LIMITS.document ? report.report + note : report.report;
|
|
482
514
|
return {
|
|
483
515
|
...report,
|
|
@@ -650,6 +682,12 @@ function scrubbedImageNames(images) {
|
|
|
650
682
|
return [one.file, name];
|
|
651
683
|
}));
|
|
652
684
|
}
|
|
685
|
+
function withoutEnv(env, names) {
|
|
686
|
+
const kept = { ...env };
|
|
687
|
+
for (const name of names)
|
|
688
|
+
delete kept[name];
|
|
689
|
+
return kept;
|
|
690
|
+
}
|
|
653
691
|
function cleanup(worktree, names) {
|
|
654
692
|
for (const name of names) {
|
|
655
693
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.23",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|