@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/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
1
|
# @frockbot/plugin-web
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 & 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
|
+
});
|