@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 CHANGED
@@ -1,3 +1,73 @@
1
1
  # @frockbot/plugin-web
2
2
 
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.
3
+ The Web Package. It contributes one runtime Contribution (`./agent`) carrying
4
+ one tool, **`web_fetch`**, and one provider-neutral contract (`./contract`) that
5
+ a search provider Package implements.
6
+
7
+ Row 47 of the parity register (`docs/research/grokbot-computer.md`) names web
8
+ search, web fetch and image generation as first-class tools, but cites a section
9
+ that is not in the register: **no input schema, bound, or error shape was ever
10
+ measured** for any of them. Everything here is FrockBot's own contract, defined
11
+ from first principles. No schema parity is claimed.
12
+
13
+ ## `web_fetch`
14
+
15
+ | | |
16
+ | -------------- | ---------------------------------------------------------------------- |
17
+ | Capability | `web-fetch`, kind `tool`, `connectionTypes: []` |
18
+ | Input | `url`, `max_bytes` ≤ 1 MiB, `format: "text" \| "markdown"` |
19
+ | Durable result | `{"url","finalUrl","status","contentType","bytes","truncated","text"}` |
20
+ | Refusal | `isError: true` with a stable reason code |
21
+ | Effect class | read-only, `idempotent: true` |
22
+ | Turn types | all four (manifest v4 `admission`) |
23
+
24
+ `web_fetch` needs no Connection: reading a public page needs no credential. The
25
+ Assignment is still the fence — the Contribution mounts nothing without an
26
+ enabled Assignment of `web-fetch`.
27
+
28
+ It is a plain outbound request, so it works while the User's Computer is
29
+ hibernated and never wakes it. A page that needs a real browser is the
30
+ Computer's job, not this tool's.
31
+
32
+ ## The outbound trust boundary
33
+
34
+ The Bot's Durable Object can reach anything workerd can reach. `./ssrf.ts` is a
35
+ pure classifier — a string in, a verdict out — and it runs before every hop and
36
+ again on every redirect target:
37
+
38
+ 1. `https:` only. No `http:`, `data:`, `file:`, `blob:`, `ftp:`.
39
+ 2. The default port, or `443` stated explicitly.
40
+ 3. No `localhost`, `*.localhost`, `*.internal`, or bare label with no dot.
41
+ 4. No IP literal outside the public ranges. Literals are **normalized first**,
42
+ so `0177.0.0.1`, `2130706433`, `0x7f000001`, `127.1` and `::ffff:127.0.0.1`
43
+ are the same refusal as `127.0.0.1`. `169.254.169.254` — the cloud metadata
44
+ address — is inside `169.254.0.0/16`.
45
+ 5. No credentials in the URL. A fixed `User-Agent` and `Accept`; no `Cookie`,
46
+ no `Authorization`, and no header the model chose.
47
+ 6. `redirect: "manual"`, at most three hops, rules 1–5 re-run on each one.
48
+ 7. The response must declare a media type on the allow list (`text/html`,
49
+ `text/plain`, `text/markdown`, `application/json`,
50
+ `application/xhtml+xml`), and a declared length over `max_bytes` is refused
51
+ outright; the body is then read under a streaming cap and reports
52
+ `truncated` when it was cut short.
53
+ 8. A refusal carries a stable reason code — `ssrf-blocked-private-address`,
54
+ `web-fetch-blocked-content-type`, … — and **never names what a host resolved
55
+ to**.
56
+
57
+ ### Known limitation: DNS rebinding
58
+
59
+ workerd exposes no resolve-then-connect hook, so a hostname cannot be pinned to
60
+ the address the request will actually reach. A name that resolves to a public
61
+ address at classification time and to `127.0.0.1` at connection time defeats
62
+ every rule above. Classification is therefore exact for IP literals and for the
63
+ known-internal name shapes, and best-effort for everything else. Closing the gap
64
+ needs a platform primitive FrockBot does not have; it is recorded here rather
65
+ than papered over.
66
+
67
+ ## `./contract` — `WebSearchV1`
68
+
69
+ The `web_search` tool definition, its bounds, its DTO and its decoder live here
70
+ so that a provider Package contributes the tool by supplying transport alone.
71
+ `@frockbot/plugin-provider-ollama-cloud` is the first implementation
72
+ (`POST {apiBaseUrl}/api/web_search`); this Package holds no transport and
73
+ depends on no provider.
package/frockbot.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "schemaVersion": 4,
3
+ "id": "web",
4
+ "displayName": "Web",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "contributions": {
8
+ "runtime": { "entry": "./agent" }
9
+ },
10
+ "permissions": ["network:web"],
11
+ "configuration": {
12
+ "settings": [],
13
+ "connectionTypes": [],
14
+ "capabilities": [
15
+ {
16
+ "id": "web-fetch",
17
+ "kind": "tool",
18
+ "connectionTypes": [],
19
+ "admission": {
20
+ "turnTypes": ["chat", "automation", "subagent"],
21
+ "subagentRoles": ["executor"]
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,14 +1,39 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-web",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./agent": "./src/agent.ts",
9
+ "./contract": "./src/contract.ts",
10
+ "./ssrf": "./src/ssrf.ts",
11
+ "./manifest": "./src/manifest.ts",
12
+ "./frockbot.json": "./frockbot.json",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "frockbot": {
16
+ "manifest": "./frockbot.json"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test src",
20
+ "typecheck": "tsc --noEmit -p tsconfig.json"
21
+ },
22
+ "dependencies": {
23
+ "@frockbot/kernel-contracts": "0.1.1",
24
+ "cordis": "4.0.0-rc.8"
25
+ },
26
+ "devDependencies": {
27
+ "@frockbot/plugin-tools": "0.1.1",
28
+ "@types/bun": "1.4.0",
29
+ "typescript": "^7.0.2"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
6
34
  "repository": {
7
35
  "type": "git",
8
36
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
37
  "directory": "packages/plugin-web"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
38
  }
14
39
  }
@@ -0,0 +1,265 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import { ToolRegistry } from "@frockbot/plugin-tools/agent";
4
+ import type { ToolExecutionContext } from "@frockbot/kernel-contracts";
5
+ import {
6
+ createConfiguredWebFetchRuntimeContribution,
7
+ createWebFetchToolDefinitionV1,
8
+ executeWebFetchV1,
9
+ extractReadableTextV1,
10
+ WEB_FETCH_MAX_BYTES_V1,
11
+ type WebFetchFn,
12
+ type WebFetchResultV1,
13
+ } from "./agent.ts";
14
+
15
+ const ENABLED_ASSIGNMENT = {
16
+ packageId: "web",
17
+ capabilityId: "web-fetch",
18
+ state: "enabled",
19
+ } as const;
20
+
21
+ function toolContext(): ToolExecutionContext {
22
+ return {
23
+ botId: "bot",
24
+ agentId: "bot",
25
+ sessionId: "session",
26
+ compositionGenerationId: "generation",
27
+ effectId: "effect-1",
28
+ turnType: "chat",
29
+ signal: new AbortController().signal,
30
+ };
31
+ }
32
+
33
+ /** A fetch that answers a script of responses and records what it was asked. */
34
+ function fakeFetch(
35
+ answers: Array<{
36
+ status?: number;
37
+ headers?: Record<string, string>;
38
+ body?: string;
39
+ }>,
40
+ ): { fetch: WebFetchFn; calls: string[] } {
41
+ const calls: string[] = [];
42
+ let index = 0;
43
+ const fetch: WebFetchFn = (input) => {
44
+ calls.push(input);
45
+ const answer = answers[Math.min(index, answers.length - 1)] ?? {};
46
+ index += 1;
47
+ return Promise.resolve(
48
+ new Response(answer.body ?? "", {
49
+ status: answer.status ?? 200,
50
+ headers: answer.headers ?? { "content-type": "text/html" },
51
+ }),
52
+ );
53
+ };
54
+ return { fetch, calls };
55
+ }
56
+
57
+ function parsed(content: string): Record<string, unknown> {
58
+ return JSON.parse(content) as Record<string, unknown>;
59
+ }
60
+
61
+ describe("web_fetch", () => {
62
+ test("returns the durable JSON shape for a readable page", async () => {
63
+ const { fetch, calls } = fakeFetch([
64
+ {
65
+ headers: { "content-type": "text/html; charset=utf-8" },
66
+ body: "<html><head><title>t</title><style>a{}</style></head><body><h1>Title</h1><p>Body &amp; more</p><script>evil()</script></body></html>",
67
+ },
68
+ ]);
69
+ const result = await executeWebFetchV1(
70
+ { url: "https://example.test/page", maxBytes: 65536, format: "text" },
71
+ { fetch },
72
+ );
73
+
74
+ expect(result.isError).toBe(false);
75
+ const body = parsed(result.content) as unknown as WebFetchResultV1;
76
+ expect(body.url).toBe("https://example.test/page");
77
+ expect(body.finalUrl).toBe("https://example.test/page");
78
+ expect(body.status).toBe(200);
79
+ expect(body.contentType).toBe("text/html");
80
+ expect(body.truncated).toBe(false);
81
+ expect(body.text).toContain("Title");
82
+ expect(body.text).toContain("Body & more");
83
+ // Script and style content is dropped whole, never handed to the model.
84
+ expect(body.text).not.toContain("evil()");
85
+ expect(body.text).not.toContain("a{}");
86
+ expect(calls).toEqual(["https://example.test/page"]);
87
+ });
88
+
89
+ test("sends a fixed identity and no credential of the Bot's", async () => {
90
+ let sent: Headers | undefined;
91
+ const fetch: WebFetchFn = (_input, init) => {
92
+ sent = new Headers(init?.headers);
93
+ return Promise.resolve(
94
+ new Response("<p>ok</p>", { headers: { "content-type": "text/html" } }),
95
+ );
96
+ };
97
+ await executeWebFetchV1(
98
+ { url: "https://example.test/", maxBytes: 65536, format: "text" },
99
+ { fetch },
100
+ );
101
+ expect(sent?.get("user-agent")).toContain("FrockBot");
102
+ expect(sent?.get("authorization")).toBeNull();
103
+ expect(sent?.get("cookie")).toBeNull();
104
+ });
105
+
106
+ test("truncates a body that runs past max_bytes and says so", async () => {
107
+ const { fetch } = fakeFetch([
108
+ {
109
+ headers: { "content-type": "text/plain" },
110
+ body: "x".repeat(20_000),
111
+ },
112
+ ]);
113
+ const result = await executeWebFetchV1(
114
+ { url: "https://example.test/big", maxBytes: 4096, format: "text" },
115
+ { fetch },
116
+ );
117
+ const body = parsed(result.content) as unknown as WebFetchResultV1;
118
+ expect(result.isError).toBe(false);
119
+ expect(body.bytes).toBe(4096);
120
+ expect(body.truncated).toBe(true);
121
+ expect(body.text.length).toBe(4096);
122
+ });
123
+
124
+ test("refuses a body whose declared length is already over the cap", async () => {
125
+ const { fetch } = fakeFetch([
126
+ {
127
+ headers: {
128
+ "content-type": "text/plain",
129
+ "content-length": String(WEB_FETCH_MAX_BYTES_V1 + 1),
130
+ },
131
+ body: "small",
132
+ },
133
+ ]);
134
+ const result = await executeWebFetchV1(
135
+ {
136
+ url: "https://example.test/huge",
137
+ maxBytes: WEB_FETCH_MAX_BYTES_V1,
138
+ format: "text",
139
+ },
140
+ { fetch },
141
+ );
142
+ expect(result.isError).toBe(true);
143
+ expect(parsed(result.content).error).toBe("web-fetch-response-too-large");
144
+ });
145
+
146
+ test("refuses a content type outside the allow list", async () => {
147
+ const { fetch } = fakeFetch([
148
+ { headers: { "content-type": "application/pdf" }, body: "%PDF-1.4" },
149
+ ]);
150
+ const result = await executeWebFetchV1(
151
+ { url: "https://example.test/doc.pdf", maxBytes: 65536, format: "text" },
152
+ { fetch },
153
+ );
154
+ expect(result.isError).toBe(true);
155
+ expect(parsed(result.content).error).toBe("web-fetch-blocked-content-type");
156
+ });
157
+
158
+ test("re-validates every redirect and refuses one into private space", async () => {
159
+ const { fetch, calls } = fakeFetch([
160
+ {
161
+ status: 302,
162
+ headers: { location: "https://169.254.169.254/latest/meta-data" },
163
+ },
164
+ ]);
165
+ const result = await executeWebFetchV1(
166
+ { url: "https://example.test/go", maxBytes: 65536, format: "text" },
167
+ { fetch },
168
+ );
169
+ expect(result.isError).toBe(true);
170
+ expect(parsed(result.content).error).toBe("ssrf-blocked-private-address");
171
+ // The private hop is never requested: the classifier runs before the fetch.
172
+ expect(calls).toEqual(["https://example.test/go"]);
173
+ });
174
+
175
+ test("follows at most three redirects", async () => {
176
+ const { fetch, calls } = fakeFetch([
177
+ { status: 302, headers: { location: "/next" } },
178
+ ]);
179
+ const result = await executeWebFetchV1(
180
+ { url: "https://example.test/a", maxBytes: 65536, format: "text" },
181
+ { fetch },
182
+ );
183
+ expect(parsed(result.content).error).toBe("web-fetch-too-many-redirects");
184
+ expect(calls.length).toBe(4);
185
+ });
186
+
187
+ test("refuses a non-https url without making a request", async () => {
188
+ const { fetch, calls } = fakeFetch([{}]);
189
+ const result = await executeWebFetchV1(
190
+ {
191
+ url: "http://169.254.169.254/latest/meta-data",
192
+ maxBytes: 65536,
193
+ format: "text",
194
+ },
195
+ { fetch },
196
+ );
197
+ expect(result.isError).toBe(true);
198
+ expect(parsed(result.content).error).toBe("ssrf-blocked-scheme");
199
+ expect(calls).toEqual([]);
200
+ });
201
+
202
+ test("renders markdown when the call asks for it", () => {
203
+ const markdown = extractReadableTextV1(
204
+ '<h2>Heading</h2><ul><li>one</li></ul><p><a href="https://example.test/x">link</a></p>',
205
+ "text/html",
206
+ "markdown",
207
+ );
208
+ expect(markdown).toContain("## Heading");
209
+ expect(markdown).toContain("- one");
210
+ expect(markdown).toContain("[link](https://example.test/x)");
211
+ });
212
+
213
+ test("declares itself idempotent, so recovery re-runs rather than guesses", () => {
214
+ expect(createWebFetchToolDefinitionV1().idempotent).toBe(true);
215
+ });
216
+
217
+ test("rejects arguments the schema does not admit", async () => {
218
+ const definition = createWebFetchToolDefinitionV1();
219
+ expect(definition.validate?.({ url: 42 })).toBe(false);
220
+ expect(
221
+ definition.validate?.({ url: "https://a.test/", format: "pdf" }),
222
+ ).toBe(false);
223
+ expect(definition.validate?.({ url: "https://a.test/" })).toBe(true);
224
+ const result = await definition.execute({ url: "" }, toolContext());
225
+ expect(result.isError).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("the web-fetch Capability Assignment", () => {
230
+ test("mounts nothing without an enabled Assignment naming it", () => {
231
+ expect(
232
+ createConfiguredWebFetchRuntimeContribution({
233
+ assignment: { ...ENABLED_ASSIGNMENT, state: "disabled" },
234
+ }),
235
+ ).toBeUndefined();
236
+ expect(
237
+ createConfiguredWebFetchRuntimeContribution({
238
+ assignment: { ...ENABLED_ASSIGNMENT, capabilityId: "something-else" },
239
+ }),
240
+ ).toBeUndefined();
241
+ expect(
242
+ createConfiguredWebFetchRuntimeContribution({
243
+ assignment: ENABLED_ASSIGNMENT,
244
+ }),
245
+ ).toBeDefined();
246
+ });
247
+
248
+ test("offers web_fetch on every turn type its manifest admits", async () => {
249
+ const root = new Context();
250
+ await root.plugin(ToolRegistry);
251
+ const plugin = createConfiguredWebFetchRuntimeContribution({
252
+ assignment: ENABLED_ASSIGNMENT,
253
+ });
254
+ expect(plugin).toBeDefined();
255
+ await root.plugin(plugin!);
256
+
257
+ for (const turnType of ["chat", "automation", "subagent"] as const) {
258
+ expect({
259
+ turnType,
260
+ names: root.tools.schemas({ turnType }).map((schema) => schema.name),
261
+ }).toEqual({ turnType, names: ["web_fetch"] });
262
+ }
263
+ await root.fiber.dispose();
264
+ });
265
+ });