@frockbot/plugin-web 0.0.0 → 0.1.1
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/README.md +71 -1
- package/frockbot.json +26 -0
- package/package.json +31 -6
- package/src/agent.test.ts +265 -0
- package/src/agent.ts +504 -0
- package/src/contract.ts +218 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +3 -0
- package/src/ssrf.test.ts +174 -0
- package/src/ssrf.ts +318 -0
- package/tsconfig.json +15 -0
package/src/agent.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// The Web Package's runtime Contribution: `web_fetch`.
|
|
2
|
+
//
|
|
3
|
+
// AUTHORITY. `web-fetch` is a Capability with no Connection: fetching a public
|
|
4
|
+
// page needs no credential, so a Bot holds the tool the moment its User grants
|
|
5
|
+
// an Assignment of it, and holds nothing when they have not. The Capability is
|
|
6
|
+
// still the fence — {@link createConfiguredWebFetchRuntimeContribution} mounts
|
|
7
|
+
// nothing without an enabled Assignment naming it.
|
|
8
|
+
//
|
|
9
|
+
// TRUST BOUNDARY. The Bot's Durable Object is the only thing between a model's
|
|
10
|
+
// URL and the platform's network, so every hop is classified by `./ssrf.ts`
|
|
11
|
+
// before it is made, and again for every `Location`. See that module for the
|
|
12
|
+
// DNS-rebinding limitation this cannot close.
|
|
13
|
+
//
|
|
14
|
+
// NO COMPUTER. `web_fetch` is a plain outbound request. It works while the
|
|
15
|
+
// User's Computer is hibernated and never wakes it; a page that needs a real
|
|
16
|
+
// browser is the Computer's job, not this tool's.
|
|
17
|
+
//
|
|
18
|
+
// EFFECT CLASS. Read-only, so `idempotent: true`: after a Durable Object
|
|
19
|
+
// eviction the registry recovers the effect by re-running the request rather
|
|
20
|
+
// than reconciling a recorded outcome. The constitution's "record intent
|
|
21
|
+
// before an external side effect" exempts effects an interface declares
|
|
22
|
+
// read-only, and this is one.
|
|
23
|
+
import type {
|
|
24
|
+
ToolDefinition,
|
|
25
|
+
ToolExecutionResult,
|
|
26
|
+
} from "@frockbot/kernel-contracts";
|
|
27
|
+
import type { Plugin } from "cordis";
|
|
28
|
+
import { classifyWebFetchUrlV1, type SsrfRefusalReasonV1 } from "./ssrf.js";
|
|
29
|
+
|
|
30
|
+
export type WebFetchFn = (
|
|
31
|
+
input: string,
|
|
32
|
+
init?: RequestInit,
|
|
33
|
+
) => Promise<Response>;
|
|
34
|
+
|
|
35
|
+
export const WEB_FETCH_TOOL_NAME_V1 = "web_fetch";
|
|
36
|
+
|
|
37
|
+
/** Hard ceiling on the body read off the wire, whatever the call asks for. */
|
|
38
|
+
export const WEB_FETCH_MAX_BYTES_V1 = 1024 * 1024;
|
|
39
|
+
/** Hard ceiling on the extracted text that reaches the durable event log. */
|
|
40
|
+
export const WEB_FETCH_MAX_TEXT_BYTES_V1 = 32 * 1024;
|
|
41
|
+
/** Rule 6: how many `Location` hops are followed, each re-classified. */
|
|
42
|
+
export const WEB_FETCH_MAX_REDIRECTS_V1 = 3;
|
|
43
|
+
export const WEB_FETCH_TIMEOUT_MS_V1 = 15_000;
|
|
44
|
+
|
|
45
|
+
/** Rule 7: the media types a Bot may read. Anything else is refused. */
|
|
46
|
+
export const WEB_FETCH_ALLOWED_CONTENT_TYPES_V1: readonly string[] = [
|
|
47
|
+
"text/html",
|
|
48
|
+
"text/plain",
|
|
49
|
+
"text/markdown",
|
|
50
|
+
"application/json",
|
|
51
|
+
"application/xhtml+xml",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Every refusal code `web_fetch` can record, SSRF codes included. */
|
|
55
|
+
export type WebFetchRefusalReasonV1 =
|
|
56
|
+
| SsrfRefusalReasonV1
|
|
57
|
+
| "web-fetch-invalid-input"
|
|
58
|
+
| "web-fetch-too-many-redirects"
|
|
59
|
+
| "web-fetch-redirect-without-location"
|
|
60
|
+
| "web-fetch-blocked-content-type"
|
|
61
|
+
| "web-fetch-response-too-large"
|
|
62
|
+
| "web-fetch-http-error"
|
|
63
|
+
| "web-fetch-failed";
|
|
64
|
+
|
|
65
|
+
export type WebFetchFormatV1 = "text" | "markdown";
|
|
66
|
+
|
|
67
|
+
export interface WebFetchRequestV1 {
|
|
68
|
+
url: string;
|
|
69
|
+
maxBytes: number;
|
|
70
|
+
format: WebFetchFormatV1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The durable `tool/result` body of a successful fetch. */
|
|
74
|
+
export interface WebFetchResultV1 {
|
|
75
|
+
url: string;
|
|
76
|
+
finalUrl: string;
|
|
77
|
+
status: number;
|
|
78
|
+
contentType: string;
|
|
79
|
+
bytes: number;
|
|
80
|
+
truncated: boolean;
|
|
81
|
+
text: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const WEB_FETCH_INPUT_SCHEMA: Record<string, unknown> = {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
url: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Absolute https URL of a public page to read.",
|
|
90
|
+
maxLength: 2048,
|
|
91
|
+
},
|
|
92
|
+
max_bytes: {
|
|
93
|
+
type: "integer",
|
|
94
|
+
description: `Stop reading the body after this many bytes, at most ${WEB_FETCH_MAX_BYTES_V1}.`,
|
|
95
|
+
minimum: 1024,
|
|
96
|
+
maximum: WEB_FETCH_MAX_BYTES_V1,
|
|
97
|
+
},
|
|
98
|
+
format: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description:
|
|
101
|
+
"How to render the page: plain text, or markdown that keeps headings, lists and links.",
|
|
102
|
+
enum: ["text", "markdown"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
required: ["url"],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
110
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function decodeWebFetchInputV1(input: unknown): WebFetchRequestV1 {
|
|
114
|
+
if (!isRecord(input)) throw new Error("web_fetch input must be an object");
|
|
115
|
+
if (typeof input.url !== "string" || input.url.trim().length === 0) {
|
|
116
|
+
throw new Error("web_fetch url must be a string");
|
|
117
|
+
}
|
|
118
|
+
const requested = input.max_bytes ?? WEB_FETCH_MAX_BYTES_V1;
|
|
119
|
+
if (
|
|
120
|
+
typeof requested !== "number" ||
|
|
121
|
+
!Number.isSafeInteger(requested) ||
|
|
122
|
+
requested < 1024 ||
|
|
123
|
+
requested > WEB_FETCH_MAX_BYTES_V1
|
|
124
|
+
) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`web_fetch max_bytes must be an integer 1024–${WEB_FETCH_MAX_BYTES_V1}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const format = input.format ?? "text";
|
|
130
|
+
if (format !== "text" && format !== "markdown") {
|
|
131
|
+
throw new Error('web_fetch format must be "text" or "markdown"');
|
|
132
|
+
}
|
|
133
|
+
return { url: input.url.trim(), maxBytes: requested, format };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A refusal names its reason code and never the address it resolved to. */
|
|
137
|
+
function refusal(
|
|
138
|
+
reason: WebFetchRefusalReasonV1,
|
|
139
|
+
message: string,
|
|
140
|
+
url: string,
|
|
141
|
+
): ToolExecutionResult {
|
|
142
|
+
return {
|
|
143
|
+
content: JSON.stringify({ url, error: reason, message }),
|
|
144
|
+
isError: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ENTITIES: Record<string, string> = {
|
|
149
|
+
amp: "&",
|
|
150
|
+
lt: "<",
|
|
151
|
+
gt: ">",
|
|
152
|
+
quot: '"',
|
|
153
|
+
apos: "'",
|
|
154
|
+
nbsp: " ",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function decodeEntities(value: string): string {
|
|
158
|
+
return value.replace(
|
|
159
|
+
/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g,
|
|
160
|
+
(match, body: string) => {
|
|
161
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
162
|
+
const code = Number.parseInt(body.slice(2), 16);
|
|
163
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match;
|
|
164
|
+
}
|
|
165
|
+
if (body.startsWith("#")) {
|
|
166
|
+
const code = Number.parseInt(body.slice(1), 10);
|
|
167
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match;
|
|
168
|
+
}
|
|
169
|
+
return ENTITIES[body.toLowerCase()] ?? match;
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reduce a page to the text a model can act on.
|
|
176
|
+
*
|
|
177
|
+
* This is a reader, not a parser: no DOM is available in a Durable Object and
|
|
178
|
+
* a real HTML parser is not worth its weight for a tool whose output is capped
|
|
179
|
+
* at 32 KiB anyway. Script, style and other non-content elements are dropped
|
|
180
|
+
* whole, block boundaries become newlines, and in `markdown` mode headings,
|
|
181
|
+
* list items and links keep their shape.
|
|
182
|
+
*/
|
|
183
|
+
export function extractReadableTextV1(
|
|
184
|
+
body: string,
|
|
185
|
+
contentType: string,
|
|
186
|
+
format: WebFetchFormatV1,
|
|
187
|
+
): string {
|
|
188
|
+
const isHtml = contentType.includes("html") || contentType.includes("xhtml");
|
|
189
|
+
if (!isHtml) return body.trim();
|
|
190
|
+
let text = body
|
|
191
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
192
|
+
.replace(
|
|
193
|
+
/<(script|style|noscript|template|svg|iframe|head)\b[\s\S]*?<\/\1>/gi,
|
|
194
|
+
" ",
|
|
195
|
+
);
|
|
196
|
+
if (format === "markdown") {
|
|
197
|
+
text = text
|
|
198
|
+
.replace(
|
|
199
|
+
/<h([1-6])\b[^>]*>/gi,
|
|
200
|
+
(_match, level: string) => `\n\n${"#".repeat(Number(level))} `,
|
|
201
|
+
)
|
|
202
|
+
.replace(/<\/h[1-6]>/gi, "\n\n")
|
|
203
|
+
.replace(/<li\b[^>]*>/gi, "\n- ")
|
|
204
|
+
.replace(
|
|
205
|
+
/<a\b[^>]*\shref=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi,
|
|
206
|
+
(match, href: string, label: string) => {
|
|
207
|
+
const inner = label.replace(/<[^>]*>/g, "").trim();
|
|
208
|
+
return inner ? `[${inner}](${href})` : match;
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
text = text
|
|
213
|
+
.replace(/<(p|div|br|tr|section|article|h[1-6]|li|ul|ol)\b[^>]*>/gi, "\n")
|
|
214
|
+
.replace(/<\/(p|div|tr|section|article|h[1-6]|li|ul|ol)>/gi, "\n")
|
|
215
|
+
.replace(/<[^>]*>/g, " ");
|
|
216
|
+
return decodeEntities(text)
|
|
217
|
+
.split("\n")
|
|
218
|
+
.map((line) => line.replace(/[^\S\n]+/g, " ").trim())
|
|
219
|
+
.join("\n")
|
|
220
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
221
|
+
.trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function boundedUtf8(value: string, maximum: number): string {
|
|
225
|
+
const encoder = new TextEncoder();
|
|
226
|
+
if (encoder.encode(value).byteLength <= maximum) return value;
|
|
227
|
+
let bounded = "";
|
|
228
|
+
let bytes = 0;
|
|
229
|
+
for (const character of value) {
|
|
230
|
+
const size = encoder.encode(character).byteLength;
|
|
231
|
+
if (bytes + size > maximum) break;
|
|
232
|
+
bounded += character;
|
|
233
|
+
bytes += size;
|
|
234
|
+
}
|
|
235
|
+
return bounded;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read at most `maximum` bytes off the wire, cancelling the body the moment
|
|
240
|
+
* the cap is reached. `truncated` distinguishes a page that ended from a page
|
|
241
|
+
* that was cut off, so the model can ask for more rather than assume it saw
|
|
242
|
+
* everything.
|
|
243
|
+
*/
|
|
244
|
+
async function readBoundedBody(
|
|
245
|
+
response: Response,
|
|
246
|
+
maximum: number,
|
|
247
|
+
): Promise<{ bytes: Uint8Array; truncated: boolean }> {
|
|
248
|
+
const chunks: Uint8Array[] = [];
|
|
249
|
+
let length = 0;
|
|
250
|
+
let truncated = false;
|
|
251
|
+
const reader = response.body?.getReader();
|
|
252
|
+
if (reader) {
|
|
253
|
+
while (true) {
|
|
254
|
+
const chunk = await reader.read();
|
|
255
|
+
if (chunk.done) break;
|
|
256
|
+
const remaining = maximum - length;
|
|
257
|
+
if (chunk.value.byteLength >= remaining) {
|
|
258
|
+
chunks.push(chunk.value.subarray(0, remaining));
|
|
259
|
+
length = maximum;
|
|
260
|
+
truncated = true;
|
|
261
|
+
await reader.cancel().catch(() => undefined);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
chunks.push(chunk.value);
|
|
265
|
+
length += chunk.value.byteLength;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const bytes = new Uint8Array(length);
|
|
269
|
+
let offset = 0;
|
|
270
|
+
for (const chunk of chunks) {
|
|
271
|
+
bytes.set(chunk, offset);
|
|
272
|
+
offset += chunk.byteLength;
|
|
273
|
+
}
|
|
274
|
+
return { bytes, truncated };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface WebFetchConfigV1 {
|
|
278
|
+
fetch?: WebFetchFn;
|
|
279
|
+
timeoutMs?: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Execute one `web_fetch`, applying SSRF rules 1–8 on the requested URL and
|
|
284
|
+
* again on every redirect target.
|
|
285
|
+
*/
|
|
286
|
+
export async function executeWebFetchV1(
|
|
287
|
+
request: WebFetchRequestV1,
|
|
288
|
+
config: WebFetchConfigV1 = {},
|
|
289
|
+
signal?: AbortSignal,
|
|
290
|
+
): Promise<ToolExecutionResult> {
|
|
291
|
+
// Workerd rejects a detached global `fetch`, so the default forwards.
|
|
292
|
+
const fetcher: WebFetchFn =
|
|
293
|
+
config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
294
|
+
let current = classifyWebFetchUrlV1(request.url);
|
|
295
|
+
if (!current.allowed) {
|
|
296
|
+
return refusal(current.reason, current.message, request.url);
|
|
297
|
+
}
|
|
298
|
+
const requested = current.url;
|
|
299
|
+
let response: Response | undefined;
|
|
300
|
+
for (let hop = 0; hop <= WEB_FETCH_MAX_REDIRECTS_V1; hop += 1) {
|
|
301
|
+
if (!current.allowed) {
|
|
302
|
+
return refusal(current.reason, current.message, requested);
|
|
303
|
+
}
|
|
304
|
+
const target = current.url;
|
|
305
|
+
let hopResponse: Response;
|
|
306
|
+
try {
|
|
307
|
+
hopResponse = await fetcher(target, {
|
|
308
|
+
method: "GET",
|
|
309
|
+
redirect: "manual",
|
|
310
|
+
// Rule 5: a fixed identity, and nothing the Bot holds. No cookie, no
|
|
311
|
+
// authorization, no header the model chose.
|
|
312
|
+
headers: {
|
|
313
|
+
accept:
|
|
314
|
+
"text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8",
|
|
315
|
+
"user-agent": "FrockBot/0.0.1 (+https://frockbot.com)",
|
|
316
|
+
"accept-language": "en",
|
|
317
|
+
},
|
|
318
|
+
...(signal ? { signal } : {}),
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return refusal(
|
|
322
|
+
"web-fetch-failed",
|
|
323
|
+
(error instanceof Error ? error.message : "the request failed").slice(
|
|
324
|
+
0,
|
|
325
|
+
200,
|
|
326
|
+
),
|
|
327
|
+
requested,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (hopResponse.status >= 300 && hopResponse.status < 400) {
|
|
331
|
+
const location = hopResponse.headers.get("location");
|
|
332
|
+
await hopResponse.body?.cancel().catch(() => undefined);
|
|
333
|
+
if (!location) {
|
|
334
|
+
return refusal(
|
|
335
|
+
"web-fetch-redirect-without-location",
|
|
336
|
+
"The site redirected without saying where.",
|
|
337
|
+
requested,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (hop === WEB_FETCH_MAX_REDIRECTS_V1) {
|
|
341
|
+
return refusal(
|
|
342
|
+
"web-fetch-too-many-redirects",
|
|
343
|
+
`The site redirected more than ${WEB_FETCH_MAX_REDIRECTS_V1} times.`,
|
|
344
|
+
requested,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
let resolved: string;
|
|
348
|
+
try {
|
|
349
|
+
resolved = new URL(location, target).toString();
|
|
350
|
+
} catch {
|
|
351
|
+
return refusal(
|
|
352
|
+
"ssrf-invalid-url",
|
|
353
|
+
"The site redirected to an address that is not a URL.",
|
|
354
|
+
requested,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
current = classifyWebFetchUrlV1(resolved);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
response = hopResponse;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
if (!response || !current.allowed) {
|
|
364
|
+
return refusal(
|
|
365
|
+
"web-fetch-too-many-redirects",
|
|
366
|
+
`The site redirected more than ${WEB_FETCH_MAX_REDIRECTS_V1} times.`,
|
|
367
|
+
requested,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const finalUrl = current.url;
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
await response.body?.cancel().catch(() => undefined);
|
|
373
|
+
return refusal(
|
|
374
|
+
"web-fetch-http-error",
|
|
375
|
+
`The site answered ${response.status}.`,
|
|
376
|
+
requested,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const contentType = (response.headers.get("content-type") ?? "")
|
|
380
|
+
.split(";")[0]
|
|
381
|
+
?.trim()
|
|
382
|
+
.toLowerCase();
|
|
383
|
+
if (
|
|
384
|
+
!contentType ||
|
|
385
|
+
!WEB_FETCH_ALLOWED_CONTENT_TYPES_V1.includes(contentType)
|
|
386
|
+
) {
|
|
387
|
+
await response.body?.cancel().catch(() => undefined);
|
|
388
|
+
return refusal(
|
|
389
|
+
"web-fetch-blocked-content-type",
|
|
390
|
+
`web_fetch reads text pages, not ${contentType || "an undeclared type"}.`,
|
|
391
|
+
requested,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const declared = Number(response.headers.get("content-length"));
|
|
395
|
+
if (Number.isFinite(declared) && declared > request.maxBytes) {
|
|
396
|
+
await response.body?.cancel().catch(() => undefined);
|
|
397
|
+
return refusal(
|
|
398
|
+
"web-fetch-response-too-large",
|
|
399
|
+
`The page declares ${declared} bytes, over the ${request.maxBytes} byte limit.`,
|
|
400
|
+
requested,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
let body: { bytes: Uint8Array; truncated: boolean };
|
|
404
|
+
try {
|
|
405
|
+
body = await readBoundedBody(response, request.maxBytes);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return refusal(
|
|
408
|
+
"web-fetch-failed",
|
|
409
|
+
(error instanceof Error
|
|
410
|
+
? error.message
|
|
411
|
+
: "the body could not be read"
|
|
412
|
+
).slice(0, 200),
|
|
413
|
+
requested,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const decoded = new TextDecoder().decode(body.bytes);
|
|
417
|
+
const extracted = extractReadableTextV1(decoded, contentType, request.format);
|
|
418
|
+
const text = boundedUtf8(extracted, WEB_FETCH_MAX_TEXT_BYTES_V1);
|
|
419
|
+
const result: WebFetchResultV1 = {
|
|
420
|
+
url: requested,
|
|
421
|
+
finalUrl,
|
|
422
|
+
status: response.status,
|
|
423
|
+
contentType,
|
|
424
|
+
bytes: body.bytes.byteLength,
|
|
425
|
+
truncated: body.truncated || text.length < extracted.length,
|
|
426
|
+
text,
|
|
427
|
+
};
|
|
428
|
+
return { content: JSON.stringify(result), isError: false };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function createWebFetchToolDefinitionV1(
|
|
432
|
+
config: WebFetchConfigV1 = {},
|
|
433
|
+
): ToolDefinition {
|
|
434
|
+
return {
|
|
435
|
+
name: WEB_FETCH_TOOL_NAME_V1,
|
|
436
|
+
// A general work tool: the reach an `executor` subagent has, and not the
|
|
437
|
+
// narrow reach of `browserUse`, `computerUse`, or the two video roles.
|
|
438
|
+
admission: { subagentRoles: ["executor"] },
|
|
439
|
+
description:
|
|
440
|
+
"Read a public https web page and return its readable text. Refuses non-public addresses.",
|
|
441
|
+
inputSchema: WEB_FETCH_INPUT_SCHEMA,
|
|
442
|
+
idempotent: true,
|
|
443
|
+
validate: (input: unknown) => {
|
|
444
|
+
try {
|
|
445
|
+
decodeWebFetchInputV1(input);
|
|
446
|
+
return true;
|
|
447
|
+
} catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
execute: async (input, context) => {
|
|
452
|
+
let request: WebFetchRequestV1;
|
|
453
|
+
try {
|
|
454
|
+
request = decodeWebFetchInputV1(input);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return refusal(
|
|
457
|
+
"web-fetch-invalid-input",
|
|
458
|
+
error instanceof Error ? error.message : "invalid input",
|
|
459
|
+
isRecord(input) && typeof input.url === "string" ? input.url : "",
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return executeWebFetchV1(request, config, context.signal);
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Mount `web_fetch` into a Bot's runtime. */
|
|
468
|
+
export function createWebRuntimePlugin(
|
|
469
|
+
config: WebFetchConfigV1 = {},
|
|
470
|
+
): Plugin.Function {
|
|
471
|
+
const plugin: Plugin.Function = (ctx) =>
|
|
472
|
+
ctx.tools.register(createWebFetchToolDefinitionV1(config), {
|
|
473
|
+
admissionCeiling: ["chat", "automation", "subagent"],
|
|
474
|
+
subagentRoleCeiling: ["executor"],
|
|
475
|
+
});
|
|
476
|
+
plugin.inject = ["tools"];
|
|
477
|
+
return plugin;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The Assignment fence. A Bot holds `web_fetch` only through an enabled
|
|
482
|
+
* Assignment of this Package's `web-fetch` Capability; without one this
|
|
483
|
+
* returns `undefined` and nothing is mounted.
|
|
484
|
+
*/
|
|
485
|
+
export function createConfiguredWebFetchRuntimeContribution(config: {
|
|
486
|
+
assignment: {
|
|
487
|
+
packageId: string;
|
|
488
|
+
capabilityId: string;
|
|
489
|
+
connectionId?: string;
|
|
490
|
+
state: string;
|
|
491
|
+
};
|
|
492
|
+
fetch?: WebFetchFn;
|
|
493
|
+
}): Plugin.Function | undefined {
|
|
494
|
+
if (
|
|
495
|
+
config.assignment.packageId !== "web" ||
|
|
496
|
+
config.assignment.capabilityId !== "web-fetch" ||
|
|
497
|
+
config.assignment.state !== "enabled"
|
|
498
|
+
) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
return createWebRuntimePlugin(config.fetch ? { fetch: config.fetch } : {});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export default createWebRuntimePlugin;
|