toolroll 0.9.20 → 0.9.22
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 +21 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +41 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +20 -4
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +4 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-browser.d.ts +48 -0
- package/dist/scout-browser.js +228 -0
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +67 -0
- package/dist/scout-report.js +108 -1
- package/dist/scout.d.ts +48 -0
- package/dist/scout.js +264 -17
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scout reaches only the public web (reviews 826, 827): every request
|
|
3
|
+
* its browser and its web fetch make goes through this local proxy, which resolves the host
|
|
4
|
+
* itself and refuses anything that is not a public internet address —
|
|
5
|
+
* loopback, private, link-local, carrier-grade NAT, multicast and reserved
|
|
6
|
+
* ranges, in IPv4 and IPv6 — then connects to the very addresses it
|
|
7
|
+
* checked, each in turn until one answers, so a name that re-resolves
|
|
8
|
+
* somewhere private gets nowhere. The one
|
|
9
|
+
* exception is the project's own demo URL, when one is set, matched by its
|
|
10
|
+
* exact scheme, host and port. Redirects and a page's own requests pass
|
|
11
|
+
* through the same check; file: never reaches a proxy, and the pinned
|
|
12
|
+
* Playwright refuses to open it.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
15
|
+
import { BlockList, connect, isIP } from "node:net";
|
|
16
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
17
|
+
/** Address ranges a scout's browser may never reach. */
|
|
18
|
+
const NOT_PUBLIC = (() => {
|
|
19
|
+
const list = new BlockList();
|
|
20
|
+
for (const [net, prefix] of [
|
|
21
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
22
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
|
|
23
|
+
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
24
|
+
])
|
|
25
|
+
list.addSubnet(net, prefix, "ipv4");
|
|
26
|
+
for (const [net, prefix] of [
|
|
27
|
+
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8],
|
|
28
|
+
])
|
|
29
|
+
list.addSubnet(net, prefix, "ipv6");
|
|
30
|
+
return list;
|
|
31
|
+
})();
|
|
32
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
33
|
+
export function isPublicAddress(address) {
|
|
34
|
+
const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
35
|
+
const family = isIP(bare);
|
|
36
|
+
if (family === 4)
|
|
37
|
+
return !NOT_PUBLIC.check(bare, "ipv4");
|
|
38
|
+
if (family !== 6)
|
|
39
|
+
return false;
|
|
40
|
+
const embedded = /^(?:::ffff:(?:0:)?|::|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/i.exec(bare)?.[1];
|
|
41
|
+
if (embedded !== undefined)
|
|
42
|
+
return isPublicAddress(embedded);
|
|
43
|
+
const mappedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare);
|
|
44
|
+
if (mappedHex !== null) {
|
|
45
|
+
const high = parseInt(mappedHex[1], 16);
|
|
46
|
+
const low = parseInt(mappedHex[2], 16);
|
|
47
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
48
|
+
}
|
|
49
|
+
if (/^::ffff:/i.test(bare))
|
|
50
|
+
return false;
|
|
51
|
+
return !NOT_PUBLIC.check(bare, "ipv6");
|
|
52
|
+
}
|
|
53
|
+
const systemLookup = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address);
|
|
54
|
+
/** The demo URL's own origin, or null when none is set or it is not an http(s) address. */
|
|
55
|
+
function demoTarget(demoUrl) {
|
|
56
|
+
if (demoUrl === undefined || demoUrl === null || demoUrl === "")
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(demoUrl);
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
61
|
+
return null;
|
|
62
|
+
const scheme = url.protocol === "http:" ? "http" : "https";
|
|
63
|
+
return { scheme, host: url.hostname.toLowerCase(), port: Number(url.port || (scheme === "http" ? 80 : 443)) };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** How long one address may take to answer before the next is tried. */
|
|
70
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
71
|
+
/**
|
|
72
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
73
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
74
|
+
*/
|
|
75
|
+
export async function connectFirst(addresses, port, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
76
|
+
for (const address of addresses) {
|
|
77
|
+
const socket = await new Promise(done => {
|
|
78
|
+
const attempt = connect({ host: address, port });
|
|
79
|
+
const give = (value) => {
|
|
80
|
+
attempt.removeAllListeners("connect").removeAllListeners("error").removeAllListeners("timeout");
|
|
81
|
+
attempt.setTimeout(0);
|
|
82
|
+
if (value === null)
|
|
83
|
+
attempt.destroy();
|
|
84
|
+
done(value);
|
|
85
|
+
};
|
|
86
|
+
attempt.setTimeout(timeoutMs);
|
|
87
|
+
attempt.once("connect", () => give(attempt));
|
|
88
|
+
attempt.once("error", () => give(null));
|
|
89
|
+
attempt.once("timeout", () => give(null));
|
|
90
|
+
});
|
|
91
|
+
if (socket !== null)
|
|
92
|
+
return socket;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
98
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
99
|
+
*/
|
|
100
|
+
export async function startScoutProxy(options = {}) {
|
|
101
|
+
const lookup = options.lookup ?? systemLookup;
|
|
102
|
+
const demo = demoTarget(options.demoUrl);
|
|
103
|
+
const sockets = new Set();
|
|
104
|
+
/** The addresses this request may reach, in the resolver's order, or why it may not. */
|
|
105
|
+
const resolve = async (target) => {
|
|
106
|
+
const host = target.host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
107
|
+
const isDemo = demo !== null && demo.scheme === target.scheme && demo.host.replace(/^\[|\]$/g, "") === host && demo.port === target.port;
|
|
108
|
+
let addresses;
|
|
109
|
+
try {
|
|
110
|
+
addresses = isIP(host) !== 0 ? [host] : await lookup(host);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, why: "the host does not resolve" };
|
|
114
|
+
}
|
|
115
|
+
if (addresses.length === 0)
|
|
116
|
+
return { ok: false, why: "the host does not resolve" };
|
|
117
|
+
if (isDemo)
|
|
118
|
+
return { ok: true, addresses };
|
|
119
|
+
if (!addresses.every(isPublicAddress))
|
|
120
|
+
return { ok: false, why: "not a public internet address" };
|
|
121
|
+
return { ok: true, addresses };
|
|
122
|
+
};
|
|
123
|
+
const refuse = (target, why) => {
|
|
124
|
+
try {
|
|
125
|
+
options.refused?.(target, why);
|
|
126
|
+
}
|
|
127
|
+
catch { /* a listener's trouble is not the browser's */ }
|
|
128
|
+
};
|
|
129
|
+
const server = createServer((incoming, outgoing) => {
|
|
130
|
+
void (async () => {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(incoming.url ?? "");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
outgoing.writeHead(400).end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol !== "http:") {
|
|
140
|
+
refuse(url.protocol, "only web pages");
|
|
141
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = { scheme: "http", host: url.hostname, port: Number(url.port || 80) };
|
|
145
|
+
const reached = await resolve(target);
|
|
146
|
+
if (!reached.ok) {
|
|
147
|
+
refuse(url.origin, reached.why);
|
|
148
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
152
|
+
if (upstream === null) {
|
|
153
|
+
outgoing.writeHead(502).end();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sockets.add(upstream);
|
|
157
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
158
|
+
const headers = { ...incoming.headers };
|
|
159
|
+
delete headers["proxy-connection"];
|
|
160
|
+
delete headers["proxy-authorization"];
|
|
161
|
+
const forward = httpRequest({ createConnection: () => upstream, method: incoming.method, path: `${url.pathname}${url.search}`, headers, setHost: false }, answer => {
|
|
162
|
+
outgoing.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
163
|
+
answer.pipe(outgoing);
|
|
164
|
+
});
|
|
165
|
+
forward.on("error", () => { if (!outgoing.headersSent)
|
|
166
|
+
outgoing.writeHead(502); outgoing.end(); });
|
|
167
|
+
incoming.pipe(forward);
|
|
168
|
+
})();
|
|
169
|
+
});
|
|
170
|
+
server.on("connect", (incoming, client, head) => {
|
|
171
|
+
sockets.add(client);
|
|
172
|
+
client.on("close", () => sockets.delete(client));
|
|
173
|
+
client.on("error", () => client.destroy());
|
|
174
|
+
void (async () => {
|
|
175
|
+
const match = /^(\[[0-9a-fA-F:.]+\]|[^:[\]]+):(\d{1,5})$/.exec(incoming.url ?? "");
|
|
176
|
+
const target = match === null ? null : { scheme: "https", host: match[1], port: Number(match[2]) };
|
|
177
|
+
const reached = target === null ? { ok: false, why: "not a host and port" } : await resolve(target);
|
|
178
|
+
if (!reached.ok) {
|
|
179
|
+
refuse(incoming.url ?? "", reached.why);
|
|
180
|
+
client.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
184
|
+
if (upstream === null) {
|
|
185
|
+
if (!client.destroyed)
|
|
186
|
+
client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sockets.add(upstream);
|
|
190
|
+
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
191
|
+
upstream.on("error", () => client.destroy());
|
|
192
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
193
|
+
if (head.length > 0)
|
|
194
|
+
upstream.write(head);
|
|
195
|
+
upstream.pipe(client);
|
|
196
|
+
client.pipe(upstream);
|
|
197
|
+
})();
|
|
198
|
+
});
|
|
199
|
+
server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
200
|
+
await new Promise((done, fail) => {
|
|
201
|
+
server.once("error", fail);
|
|
202
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", fail); done(); });
|
|
203
|
+
});
|
|
204
|
+
const address = server.address();
|
|
205
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
206
|
+
return {
|
|
207
|
+
url: `http://127.0.0.1:${port}`,
|
|
208
|
+
close: () => new Promise(done => {
|
|
209
|
+
for (const socket of sockets)
|
|
210
|
+
socket.destroy();
|
|
211
|
+
server.close(() => done());
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/scout-report.d.ts
CHANGED
|
@@ -20,6 +20,24 @@ export type ParsedReport = {
|
|
|
20
20
|
title: string;
|
|
21
21
|
goal: string;
|
|
22
22
|
}[];
|
|
23
|
+
/** What the scout found, as a short list later steps can read: each cites its URL, and may show one of `images`. */
|
|
24
|
+
items: ReportItem[];
|
|
25
|
+
/** Screenshots the scout saved during the run. From the scout, `file` names a file in its output folder; once
|
|
26
|
+
* stored, the runner adds the bytes' sha256 and the evidence row that holds them. */
|
|
27
|
+
images: ReportImage[];
|
|
28
|
+
};
|
|
29
|
+
export type ReportItem = {
|
|
30
|
+
title: string;
|
|
31
|
+
why: string;
|
|
32
|
+
url: string;
|
|
33
|
+
image: string | null;
|
|
34
|
+
};
|
|
35
|
+
export type ReportImage = {
|
|
36
|
+
file: string;
|
|
37
|
+
caption: string;
|
|
38
|
+
url: string;
|
|
39
|
+
sha256?: string;
|
|
40
|
+
artifact?: number;
|
|
23
41
|
};
|
|
24
42
|
export type ReportParseResult = {
|
|
25
43
|
ok: true;
|
|
@@ -38,7 +56,15 @@ export declare const REPORT_LIMITS: {
|
|
|
38
56
|
readonly followUps: 5;
|
|
39
57
|
readonly followUpTitle: 200;
|
|
40
58
|
readonly followUpGoal: 2000;
|
|
59
|
+
readonly items: 6;
|
|
60
|
+
readonly itemTitle: 200;
|
|
61
|
+
readonly itemWhy: 1000;
|
|
62
|
+
readonly url: 2000;
|
|
63
|
+
readonly images: 8;
|
|
64
|
+
readonly caption: 300;
|
|
41
65
|
};
|
|
66
|
+
/** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
|
|
67
|
+
export declare const REPORT_IMAGE_FILE: RegExp;
|
|
42
68
|
/**
|
|
43
69
|
* The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
|
|
44
70
|
* mode only lets a session write its own plan file, so a Claude scout
|
|
@@ -82,6 +108,47 @@ export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
|
|
|
82
108
|
readonly additionalProperties: false;
|
|
83
109
|
};
|
|
84
110
|
};
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "array";
|
|
113
|
+
readonly items: {
|
|
114
|
+
readonly type: "object";
|
|
115
|
+
readonly properties: {
|
|
116
|
+
readonly title: {
|
|
117
|
+
readonly type: "string";
|
|
118
|
+
};
|
|
119
|
+
readonly why: {
|
|
120
|
+
readonly type: "string";
|
|
121
|
+
};
|
|
122
|
+
readonly url: {
|
|
123
|
+
readonly type: "string";
|
|
124
|
+
};
|
|
125
|
+
readonly image: {
|
|
126
|
+
readonly type: "string";
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
readonly required: readonly ["title", "why", "url"];
|
|
130
|
+
readonly additionalProperties: false;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
readonly images: {
|
|
134
|
+
readonly type: "array";
|
|
135
|
+
readonly items: {
|
|
136
|
+
readonly type: "object";
|
|
137
|
+
readonly properties: {
|
|
138
|
+
readonly file: {
|
|
139
|
+
readonly type: "string";
|
|
140
|
+
};
|
|
141
|
+
readonly caption: {
|
|
142
|
+
readonly type: "string";
|
|
143
|
+
};
|
|
144
|
+
readonly url: {
|
|
145
|
+
readonly type: "string";
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
readonly required: readonly ["file", "caption", "url"];
|
|
149
|
+
readonly additionalProperties: false;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
85
152
|
};
|
|
86
153
|
readonly required: readonly ["title", "summary", "report"];
|
|
87
154
|
readonly additionalProperties: false;
|
package/dist/scout-report.js
CHANGED
|
@@ -17,7 +17,15 @@ export const REPORT_LIMITS = {
|
|
|
17
17
|
followUps: 5,
|
|
18
18
|
followUpTitle: 200,
|
|
19
19
|
followUpGoal: 2_000,
|
|
20
|
+
items: 6,
|
|
21
|
+
itemTitle: 200,
|
|
22
|
+
itemWhy: 1_000,
|
|
23
|
+
url: 2_000,
|
|
24
|
+
images: 8,
|
|
25
|
+
caption: 300,
|
|
20
26
|
};
|
|
27
|
+
/** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
|
|
28
|
+
export const REPORT_IMAGE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(?:png|jpe?g)$/i;
|
|
21
29
|
const REPORT_SHAPE = {
|
|
22
30
|
type: "object",
|
|
23
31
|
properties: {
|
|
@@ -33,6 +41,24 @@ const REPORT_SHAPE = {
|
|
|
33
41
|
additionalProperties: false,
|
|
34
42
|
},
|
|
35
43
|
},
|
|
44
|
+
items: {
|
|
45
|
+
type: "array",
|
|
46
|
+
items: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: { title: { type: "string" }, why: { type: "string" }, url: { type: "string" }, image: { type: "string" } },
|
|
49
|
+
required: ["title", "why", "url"],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
images: {
|
|
54
|
+
type: "array",
|
|
55
|
+
items: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { file: { type: "string" }, caption: { type: "string" }, url: { type: "string" } },
|
|
58
|
+
required: ["file", "caption", "url"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
36
62
|
},
|
|
37
63
|
required: ["title", "summary", "report"],
|
|
38
64
|
additionalProperties: false,
|
|
@@ -165,7 +191,88 @@ export function parseReport(raw) {
|
|
|
165
191
|
}
|
|
166
192
|
}
|
|
167
193
|
}
|
|
194
|
+
const images = listOf(body["images"], "images", REPORT_LIMITS.images, problems, (entry, at) => {
|
|
195
|
+
// Any one line here: the runner refuses an image that isn't a plain file name in its output folder, not the report.
|
|
196
|
+
const file = prose(entry["file"], `${at}.file`, REPORT_LIMITS.caption, problems);
|
|
197
|
+
const caption = prose(entry["caption"], `${at}.caption`, REPORT_LIMITS.caption, problems);
|
|
198
|
+
const url = link(entry["url"], `${at}.url`, problems);
|
|
199
|
+
if (file !== null && /[\n\r]/.test(file))
|
|
200
|
+
problems.push({ reason: `${at}-file-multiline`, message: `${at}.file must be one line` });
|
|
201
|
+
if (caption !== null && /[\n\r]/.test(caption))
|
|
202
|
+
problems.push({ reason: `${at}-caption-multiline`, message: `${at}.caption must be one line` });
|
|
203
|
+
const sha256 = entry["sha256"];
|
|
204
|
+
if (sha256 !== undefined && (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)))
|
|
205
|
+
problems.push({ reason: `${at}-sha256`, message: `${at}.sha256 must be a sha256 hex digest` });
|
|
206
|
+
const artifact = entry["artifact"];
|
|
207
|
+
if (artifact !== undefined && (typeof artifact !== "number" || !Number.isSafeInteger(artifact) || artifact <= 0))
|
|
208
|
+
problems.push({ reason: `${at}-artifact`, message: `${at}.artifact must be an evidence id` });
|
|
209
|
+
if (file === null || caption === null || url === null || /[\n\r]/.test(file) || /[\n\r]/.test(caption))
|
|
210
|
+
return null;
|
|
211
|
+
return { file, caption, url, ...(typeof sha256 === "string" ? { sha256 } : {}), ...(typeof artifact === "number" ? { artifact } : {}) };
|
|
212
|
+
});
|
|
213
|
+
const files = new Set();
|
|
214
|
+
for (const one of images) {
|
|
215
|
+
if (files.has(one.file))
|
|
216
|
+
problems.push({ reason: "images-duplicate", message: `images names ${one.file} twice` });
|
|
217
|
+
files.add(one.file);
|
|
218
|
+
}
|
|
219
|
+
const items = listOf(body["items"], "items", REPORT_LIMITS.items, problems, (entry, at) => {
|
|
220
|
+
const itemTitle = prose(entry["title"], `${at}.title`, REPORT_LIMITS.itemTitle, problems);
|
|
221
|
+
const why = prose(entry["why"], `${at}.why`, REPORT_LIMITS.itemWhy, problems);
|
|
222
|
+
const url = link(entry["url"], `${at}.url`, problems);
|
|
223
|
+
const image = entry["image"] === undefined || entry["image"] === null || entry["image"] === "" ? null : entry["image"];
|
|
224
|
+
if (itemTitle !== null && /[\n\r]/.test(itemTitle))
|
|
225
|
+
problems.push({ reason: `${at}-title-multiline`, message: `${at}.title must be one line` });
|
|
226
|
+
if (image !== null && (typeof image !== "string" || !files.has(image)))
|
|
227
|
+
problems.push({ reason: `${at}-image`, message: `${at}.image must name one of images by its file` });
|
|
228
|
+
if (itemTitle === null || why === null || url === null || /[\n\r]/.test(itemTitle) || (image !== null && (typeof image !== "string" || !files.has(image))))
|
|
229
|
+
return null;
|
|
230
|
+
return { title: itemTitle, why, url, image: image };
|
|
231
|
+
});
|
|
168
232
|
if (problems.length > 0)
|
|
169
233
|
return { ok: false, problems };
|
|
170
|
-
return { ok: true, report: { title: title, summary: summary, report: document, followUps } };
|
|
234
|
+
return { ok: true, report: { title: title, summary: summary, report: document, followUps, items, images } };
|
|
235
|
+
}
|
|
236
|
+
/** An optional capped list of objects, each read by `one`; problems are reported, never thrown. */
|
|
237
|
+
function listOf(value, field, cap, problems, one) {
|
|
238
|
+
if (value === undefined || value === null)
|
|
239
|
+
return [];
|
|
240
|
+
if (!Array.isArray(value)) {
|
|
241
|
+
problems.push({ reason: `bad-${field}`, message: `${field} must be an array (got ${describe(value)})` });
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
if (value.length > cap) {
|
|
245
|
+
problems.push({ reason: `${field}-too-many`, message: `${field} lists ${value.length} — cap is ${cap}` });
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
const kept = [];
|
|
249
|
+
for (const [index, entry] of value.entries()) {
|
|
250
|
+
const at = `${field}[${index}]`;
|
|
251
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
252
|
+
problems.push({ reason: `${at}-shape`, message: `${at} must be an object` });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const read = one(entry, at);
|
|
256
|
+
if (read !== null)
|
|
257
|
+
kept.push(read);
|
|
258
|
+
}
|
|
259
|
+
return kept;
|
|
260
|
+
}
|
|
261
|
+
/** A cited web address: http or https, one line, no credentials. */
|
|
262
|
+
function link(value, field, problems) {
|
|
263
|
+
const text = prose(value, field, REPORT_LIMITS.url, problems);
|
|
264
|
+
if (text === null)
|
|
265
|
+
return null;
|
|
266
|
+
let url = null;
|
|
267
|
+
try {
|
|
268
|
+
url = new URL(text);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
url = null;
|
|
272
|
+
}
|
|
273
|
+
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:") || /\s/.test(text) || url.username !== "" || url.password !== "") {
|
|
274
|
+
problems.push({ reason: `${field}-not-a-link`, message: `${field} must be an http or https address` });
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return text;
|
|
171
278
|
}
|
package/dist/scout.d.ts
CHANGED
|
@@ -2,6 +2,31 @@ import type { Store } from "./store.js";
|
|
|
2
2
|
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
|
+
import { type ToolSpec } from "./project-tools.js";
|
|
6
|
+
import * as browserCheck from "./scout-browser.js";
|
|
7
|
+
/** The scout's own headless browser: the pinned Playwright tool, saving its
|
|
8
|
+
* screenshots straight into the run's image folder. */
|
|
9
|
+
export declare const SCOUT_BROWSER = "toolroll-browser";
|
|
10
|
+
/**
|
|
11
|
+
* What a Claude scout may use without asking (`dontAsk` denies the rest):
|
|
12
|
+
* reading the checkout (always allowed), web search and fetch for research,
|
|
13
|
+
* and its browser for screenshots. Nothing that edits a file or runs a
|
|
14
|
+
* command, so the read-only posture holds by permission as well as by the
|
|
15
|
+
* clean-tree proof.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SCOUT_ALLOWED_TOOLS: readonly string[];
|
|
18
|
+
/** The scout's browser launch, or null when this install has no Playwright entry (the scout then researches without
|
|
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>;
|
|
26
|
+
/** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
|
|
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>;
|
|
5
30
|
export type ScoutRequest = {
|
|
6
31
|
/** v105: what's left of a monthly budget this API-key work counts toward (the CLI's own cap), when one does. */
|
|
7
32
|
maxBudgetUsd?: number;
|
|
@@ -35,6 +60,13 @@ export type ScoutRequest = {
|
|
|
35
60
|
choice: string;
|
|
36
61
|
note: string | null;
|
|
37
62
|
}[];
|
|
63
|
+
/** Where the scout may save screenshots: outside the checkout, so the clean-tree proof still holds. Left out, a
|
|
64
|
+
* fresh temporary folder is made for this run and removed after it. */
|
|
65
|
+
outputDir?: string;
|
|
66
|
+
/** The project's own demo or dev server, the one non-public address the scout's browser may open. */
|
|
67
|
+
demoUrl?: string | null;
|
|
68
|
+
/** The browser to launch; left out, the catalog's Playwright entry. Null runs the scout without one. */
|
|
69
|
+
browserTool?: Pick<ToolSpec, "command" | "args"> | null;
|
|
38
70
|
};
|
|
39
71
|
export type ReportArtifact = {
|
|
40
72
|
key: string;
|
|
@@ -67,3 +99,19 @@ export type ScoutOutcome = {
|
|
|
67
99
|
problems?: (Problem | ReportProblem)[];
|
|
68
100
|
};
|
|
69
101
|
export declare function scout(store: Store, request: ScoutRequest): Promise<ScoutOutcome>;
|
|
102
|
+
/** One screenshot from the scout's output folder: a plain file name (never a path), a regular file (never a link),
|
|
103
|
+
* at most SCREENSHOT_BYTE_CAP, and a PNG or JPEG by its signature. */
|
|
104
|
+
export declare function readReportImage(folder: string, file: string): {
|
|
105
|
+
ok: true;
|
|
106
|
+
bytes: Buffer;
|
|
107
|
+
kind: "png" | "jpeg";
|
|
108
|
+
} | {
|
|
109
|
+
ok: false;
|
|
110
|
+
problem: string;
|
|
111
|
+
};
|
|
112
|
+
/** A cited link without its secrets: credential-shaped query values and path parts become REDACTED, a fragment
|
|
113
|
+
* that carries one is dropped, and a link still showing one keeps only its origin. Unchanged when it has none. */
|
|
114
|
+
export declare function scrubUrl(text: string): {
|
|
115
|
+
url: string;
|
|
116
|
+
scrubbed: boolean;
|
|
117
|
+
};
|