pi-roundtable-sandbox 0.7.0 → 0.7.3

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.
@@ -0,0 +1,397 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
6
+
7
+ export class UnsafeUrlError extends Error {
8
+ override name = "UnsafeUrlError";
9
+ }
10
+ /** The response declared a body over the limit. The status and headers let a caller report the size. */
11
+ export class ResponseTooLargeError extends UnsafeUrlError {
12
+ constructor(
13
+ message: string,
14
+ readonly status: number,
15
+ readonly headers: Headers,
16
+ ) {
17
+ super(message);
18
+ }
19
+ }
20
+ export interface ResolvedAddress {
21
+ address: string;
22
+ family: number;
23
+ }
24
+ export interface SafeFetchOptions {
25
+ signal?: AbortSignal;
26
+ timeoutMs?: number;
27
+ maxBytes?: number;
28
+ maxRedirects?: number;
29
+ /**
30
+ * Return a redirect response instead of following it, so a caller that handles redirects
31
+ * itself can send each hop back through this function. Default: follow.
32
+ */
33
+ followRedirects?: boolean;
34
+ /** Trusted host request headers (never guest input); the accept-encoding header is always fixed. */
35
+ headers?: Record<string, string>;
36
+ /** Trusted test seam; never expose to guest input. */
37
+ resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
38
+ /** Trusted test seam. Production transport pins the socket lookup to this address. */
39
+ transport?: (
40
+ url: URL,
41
+ address: ResolvedAddress,
42
+ signal: AbortSignal,
43
+ maxBytes: number,
44
+ headers?: Record<string, string>,
45
+ ) => Promise<SafeFetchResult>;
46
+ }
47
+ export interface SafeFetchResult {
48
+ url: string;
49
+ status: number;
50
+ /** The server's reason phrase, when it sent one. */
51
+ statusText?: string;
52
+ headers: Headers;
53
+ data: Uint8Array;
54
+ }
55
+
56
+ /** Conservative globally routable unicast policy; deny special-purpose and transition ranges. */
57
+ export function isPublicAddress(raw: string): boolean {
58
+ let address = raw.toLowerCase().replace(/^\[|\]$/g, "");
59
+ if (address.includes("%")) return false;
60
+ if (isIP(address) === 4) {
61
+ const [a = 0, b = 0, c = 0] = address.split(".").map(Number);
62
+ return !(
63
+ a === 0 ||
64
+ a === 10 ||
65
+ a === 127 ||
66
+ a >= 224 ||
67
+ (a === 100 && b >= 64 && b <= 127) ||
68
+ (a === 169 && b === 254) ||
69
+ (a === 172 && b >= 16 && b <= 31) ||
70
+ (a === 192 &&
71
+ (b === 168 ||
72
+ (b === 0 && (c === 0 || c === 2)) ||
73
+ (b === 88 && c === 99))) ||
74
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
75
+ (a === 203 && b === 0 && c === 113)
76
+ );
77
+ }
78
+ if (isIP(address) !== 6) return false;
79
+ // URL canonicalization normalizes expanded IPv6 and dotted IPv4-mapped representations.
80
+ // pi-lens-ignore: unchecked-throwing-call -- isIP validated IPv6 and scope identifiers were already refused.
81
+ address = new URL(`http://[${address}]/`).hostname.slice(1, -1);
82
+ if (address.startsWith("::ffff:")) {
83
+ const parts = address.slice(7).split(":");
84
+ if (parts.length !== 2) return false;
85
+ const high = Number.parseInt(parts[0] ?? "", 16);
86
+ const low = Number.parseInt(parts[1] ?? "", 16);
87
+ return isPublicAddress(
88
+ `${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`,
89
+ );
90
+ }
91
+ // Only ordinary global IPv6, excluding documentation, benchmarking, Teredo and 6to4.
92
+ // Hextets are read numerically after expansion, so compressed forms such as 2001::1 are caught too.
93
+ const [head = "", tail = ""] = address.split("::");
94
+ const left = head ? head.split(":") : [];
95
+ const right = tail ? tail.split(":") : [];
96
+ const hextets = address.includes("::")
97
+ ? [
98
+ ...left,
99
+ ...Array<string>(Math.max(0, 8 - left.length - right.length)).fill("0"),
100
+ ...right,
101
+ ]
102
+ : left;
103
+ const first = Number.parseInt(hextets[0] ?? "", 16);
104
+ const second = Number.parseInt(hextets[1] ?? "", 16);
105
+ return (
106
+ first >= 0x2000 &&
107
+ first <= 0x3fff &&
108
+ first !== 0x2002 &&
109
+ !(
110
+ first === 0x2001 &&
111
+ (second === 0 ||
112
+ second === 2 ||
113
+ second === 0xdb8 ||
114
+ (second >= 0x10 && second <= 0x2f))
115
+ ) &&
116
+ first !== 0x3fff
117
+ );
118
+ }
119
+
120
+ function target(raw: string): URL {
121
+ let url: URL;
122
+ try {
123
+ url = new URL(raw);
124
+ } catch {
125
+ throw new UnsafeUrlError("Invalid URL");
126
+ }
127
+ if (
128
+ !["http:", "https:"].includes(url.protocol) ||
129
+ url.username ||
130
+ url.password
131
+ )
132
+ throw new UnsafeUrlError("Only credential-free HTTP(S) URLs are allowed");
133
+ url.hash = "";
134
+ return url;
135
+ }
136
+
137
+ /** No proxy environment or second DNS resolution is used; TLS still validates the original hostname. */
138
+ export async function pinnedTransport(
139
+ url: URL,
140
+ address: ResolvedAddress,
141
+ signal: AbortSignal,
142
+ maxBytes: number,
143
+ extraHeaders: Record<string, string> = {},
144
+ ): Promise<SafeFetchResult> {
145
+ return new Promise((resolve, reject) => {
146
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
147
+ url,
148
+ {
149
+ signal,
150
+ agent: false,
151
+ family: address.family,
152
+ headers: {
153
+ accept:
154
+ "text/html,application/pdf,text/plain,application/json;q=0.9,*/*;q=0.1",
155
+ "user-agent": "pi-roundtable-sandbox",
156
+ ...extraHeaders,
157
+ "accept-encoding": "identity",
158
+ },
159
+ lookup: (_hostname, _options, callback) =>
160
+ callback(null, address.address, address.family),
161
+ },
162
+ (response) => {
163
+ const headers = new Headers();
164
+ for (const [name, value] of Object.entries(response.headers))
165
+ if (value !== undefined)
166
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
167
+ const status = response.statusCode ?? 502;
168
+ if ([301, 302, 303, 307, 308].includes(status)) {
169
+ response.destroy();
170
+ resolve({
171
+ url: url.href,
172
+ status,
173
+ statusText: response.statusMessage,
174
+ headers,
175
+ data: new Uint8Array(),
176
+ });
177
+ return;
178
+ }
179
+ const length = Number(headers.get("content-length"));
180
+ const encoding =
181
+ headers.get("content-encoding")?.toLowerCase() ?? "identity";
182
+ if (length > maxBytes) {
183
+ response.destroy();
184
+ reject(
185
+ new ResponseTooLargeError(
186
+ "Response exceeds bounds",
187
+ status,
188
+ headers,
189
+ ),
190
+ );
191
+ return;
192
+ }
193
+ if (!["identity", "gzip", "deflate", "br"].includes(encoding)) {
194
+ response.destroy();
195
+ reject(
196
+ new UnsafeUrlError(
197
+ "Response exceeds bounds or uses unsupported encoding",
198
+ ),
199
+ );
200
+ return;
201
+ }
202
+ const decoder =
203
+ encoding === "gzip"
204
+ ? createGunzip()
205
+ : encoding === "deflate"
206
+ ? createInflate()
207
+ : encoding === "br"
208
+ ? createBrotliDecompress()
209
+ : undefined;
210
+ let wireBytes = 0;
211
+ response.on("data", (chunk: Buffer) => {
212
+ wireBytes += chunk.length;
213
+ if (wireBytes > maxBytes) {
214
+ response.destroy();
215
+ decoder?.destroy();
216
+ reject(new UnsafeUrlError("Wire body too large"));
217
+ }
218
+ });
219
+ const body = decoder ? response.pipe(decoder) : response;
220
+ const chunks: Buffer[] = [];
221
+ let size = 0;
222
+ body.on("data", (chunk: Buffer) => {
223
+ size += chunk.length;
224
+ if (size > maxBytes) {
225
+ response.destroy();
226
+ decoder?.destroy();
227
+ reject(new UnsafeUrlError("Response body too large"));
228
+ } else chunks.push(chunk);
229
+ });
230
+ response.on("error", (error) => {
231
+ decoder?.destroy();
232
+ reject(error);
233
+ });
234
+ body.on("error", (error) => {
235
+ response.destroy();
236
+ reject(error);
237
+ });
238
+ body.on("end", () => {
239
+ // A decoded body no longer matches the length the server sent; a body sent without
240
+ // one keeps none, so a caller reads it with its own limit as it would over fetch.
241
+ if (decoder) headers.delete("content-length");
242
+ headers.delete("content-encoding");
243
+ resolve({
244
+ url: url.href,
245
+ status,
246
+ statusText: response.statusMessage,
247
+ headers,
248
+ data: Buffer.concat(chunks, size),
249
+ });
250
+ });
251
+ },
252
+ );
253
+ let connectTimer: ReturnType<typeof setTimeout> | undefined;
254
+ request.on("socket", (socket) => {
255
+ connectTimer = setTimeout(
256
+ () =>
257
+ request.destroy(
258
+ Object.assign(new Error("Connection deadline exceeded"), {
259
+ code: "ETIMEDOUT",
260
+ }),
261
+ ),
262
+ 3000,
263
+ );
264
+ socket.once(url.protocol === "https:" ? "secureConnect" : "connect", () =>
265
+ clearTimeout(connectTimer),
266
+ );
267
+ });
268
+ request.on("error", (error) => {
269
+ clearTimeout(connectTimer);
270
+ reject(error);
271
+ });
272
+ request.on("close", () => clearTimeout(connectTimer));
273
+ request.end();
274
+ });
275
+ }
276
+
277
+ /** Every DNS answer for the host must be a public address; a mixed answer is refused. */
278
+ async function vetAddresses(
279
+ url: URL,
280
+ resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>,
281
+ ): Promise<readonly ResolvedAddress[]> {
282
+ const host = url.hostname.replace(/^\[|\]$/g, "");
283
+ const addresses = isIP(host)
284
+ ? [{ address: host, family: isIP(host) }]
285
+ : await (
286
+ resolve ??
287
+ ((hostname) => lookup(hostname, { all: true, verbatim: true }))
288
+ )(host);
289
+ if (
290
+ !addresses.length ||
291
+ addresses.some(
292
+ (entry) =>
293
+ !isPublicAddress(entry.address) || isIP(entry.address) !== entry.family,
294
+ )
295
+ )
296
+ throw new UnsafeUrlError("URL resolves to a non-public address");
297
+ return addresses;
298
+ }
299
+
300
+ /**
301
+ * Refuse a URL that is not credential-free HTTP(S) or does not resolve only to public addresses.
302
+ * For handing a guest-supplied URL to a third-party fetcher; it does not pin a later connection.
303
+ */
304
+ export async function assertPublicUrl(
305
+ raw: string,
306
+ resolve?: (hostname: string) => Promise<readonly ResolvedAddress[]>,
307
+ ): Promise<URL> {
308
+ const url = target(raw);
309
+ await vetAddresses(url, resolve);
310
+ return url;
311
+ }
312
+
313
+ /** Resolve and validate every hop, then pin its actual socket to a vetted address. */
314
+ export async function safeFetch(
315
+ raw: string,
316
+ options: SafeFetchOptions = {},
317
+ ): Promise<SafeFetchResult> {
318
+ const maxBytes = options.maxBytes ?? 5 * 1024 * 1024;
319
+ const timeoutMs = options.timeoutMs ?? 60_000;
320
+ const redirects = options.maxRedirects ?? 5;
321
+ if (
322
+ !Number.isSafeInteger(maxBytes) ||
323
+ maxBytes < 1 ||
324
+ maxBytes > 32 * 1024 * 1024 ||
325
+ !Number.isSafeInteger(timeoutMs) ||
326
+ timeoutMs < 1 ||
327
+ timeoutMs > 120_000 ||
328
+ !Number.isSafeInteger(redirects) ||
329
+ redirects < 0 ||
330
+ redirects > 10
331
+ )
332
+ throw new UnsafeUrlError("Invalid fetch bounds");
333
+ const signal = AbortSignal.any([
334
+ ...(options.signal ? [options.signal] : []),
335
+ AbortSignal.timeout(timeoutMs),
336
+ ]);
337
+ const cancelled = new Promise<never>((_resolve, reject) => {
338
+ if (signal.aborted) reject(signal.reason);
339
+ else
340
+ signal.addEventListener("abort", () => reject(signal.reason), {
341
+ once: true,
342
+ });
343
+ });
344
+ return Promise.race([
345
+ cancelled,
346
+ (async () => {
347
+ let url = target(raw);
348
+ for (let hop = 0; ; hop++) {
349
+ signal.throwIfAborted();
350
+ const addresses = await vetAddresses(url, options.resolve);
351
+ let result: SafeFetchResult | undefined;
352
+ for (const address of addresses) {
353
+ signal.throwIfAborted();
354
+ try {
355
+ result = await (options.transport ?? pinnedTransport)(
356
+ url,
357
+ address,
358
+ signal,
359
+ maxBytes,
360
+ options.headers,
361
+ );
362
+ break;
363
+ } catch (error) {
364
+ if (
365
+ signal.aborted ||
366
+ !(
367
+ error instanceof Error &&
368
+ "code" in error &&
369
+ [
370
+ "ECONNREFUSED",
371
+ "ECONNRESET",
372
+ "EHOSTUNREACH",
373
+ "ENETUNREACH",
374
+ "ETIMEDOUT",
375
+ ].includes(String(error.code))
376
+ )
377
+ )
378
+ throw error;
379
+ }
380
+ }
381
+ if (!result)
382
+ throw new UnsafeUrlError("No vetted address could be reached");
383
+ if (result.data.byteLength > maxBytes)
384
+ throw new UnsafeUrlError("Response body too large");
385
+ if (
386
+ options.followRedirects === false ||
387
+ ![301, 302, 303, 307, 308].includes(result.status)
388
+ )
389
+ return result;
390
+ if (hop >= redirects) throw new UnsafeUrlError("Too many redirects");
391
+ const location = result.headers.get("location");
392
+ if (!location) throw new UnsafeUrlError("Redirect without location");
393
+ url = target(new URL(location, url).href);
394
+ }
395
+ })(),
396
+ ]);
397
+ }
@@ -0,0 +1,120 @@
1
+ import type {
2
+ ChannelKey,
3
+ DelegationJob,
4
+ DelegationOutcome,
5
+ Logger,
6
+ } from "pi-roundtable";
7
+ import { DelegationError } from "pi-roundtable";
8
+
9
+ export interface ScopedDelegatorOptions {
10
+ target: string;
11
+ maxRunning?: number;
12
+ timeoutMs?: number;
13
+ run(
14
+ task: string,
15
+ context: {
16
+ channel: ChannelKey;
17
+ author: { id: string; name: string };
18
+ signal: AbortSignal;
19
+ },
20
+ ): Promise<string>;
21
+ deliver(job: DelegationJob, result: DelegationOutcome): Promise<void>;
22
+ logger: Logger;
23
+ }
24
+ /** A fixed channel-local target, host-bound author and report destination; no generic host agent dispatcher. */
25
+ export class ScopedSandboxDelegator {
26
+ readonly #options: ScopedDelegatorOptions;
27
+ readonly #jobs = new Map<
28
+ number,
29
+ { controller: AbortController; channel: ChannelKey; done: Promise<void> }
30
+ >();
31
+ #next = 1;
32
+ #closed = false;
33
+ constructor(options: ScopedDelegatorOptions) {
34
+ if (
35
+ !options.target ||
36
+ !Number.isSafeInteger(options.maxRunning ?? 2) ||
37
+ (options.maxRunning ?? 2) < 1 ||
38
+ (options.maxRunning ?? 2) > 10 ||
39
+ !Number.isSafeInteger(options.timeoutMs ?? 600_000) ||
40
+ (options.timeoutMs ?? 600_000) < 1000 ||
41
+ (options.timeoutMs ?? 600_000) > 1_200_000
42
+ )
43
+ throw new Error("Invalid delegation target/budget");
44
+ this.#options = options;
45
+ }
46
+ start(
47
+ request: Omit<DelegationJob, "id" | "startedAt" | "thread">,
48
+ ): DelegationJob {
49
+ if (
50
+ this.#closed ||
51
+ request.target !== this.#options.target ||
52
+ request.origin ||
53
+ request.author.tier
54
+ )
55
+ throw new DelegationError("Delegation scope refused");
56
+ if (
57
+ !request.title.trim() ||
58
+ request.title.length > 200 ||
59
+ !request.task.trim() ||
60
+ request.task.length > 4000
61
+ )
62
+ throw new DelegationError("Invalid delegation title/task");
63
+ if (
64
+ [...this.#jobs.values()].filter((job) => job.channel === request.channel)
65
+ .length >= (this.#options.maxRunning ?? 2)
66
+ )
67
+ throw new DelegationError("This channel has too many running tasks");
68
+ const job: DelegationJob = {
69
+ id: this.#next++,
70
+ channel: request.channel,
71
+ target: this.#options.target,
72
+ author: { id: request.author.id, name: request.author.name },
73
+ title: request.title.trim(),
74
+ task: request.task.trim(),
75
+ startedAt: new Date(),
76
+ };
77
+ const controller = new AbortController();
78
+ const signal = AbortSignal.any([
79
+ controller.signal,
80
+ AbortSignal.timeout(this.#options.timeoutMs ?? 600_000),
81
+ ]);
82
+ const done = (async () => {
83
+ let result: DelegationOutcome;
84
+ try {
85
+ const report = await this.#options.run(job.task, {
86
+ channel: job.channel,
87
+ author: job.author,
88
+ signal,
89
+ });
90
+ signal.throwIfAborted();
91
+ if (typeof report !== "string" || report.length > 80_000)
92
+ throw new Error("Research report too large");
93
+ result = { ok: true, report };
94
+ } catch {
95
+ result = { ok: false, error: "Sandbox task failed" };
96
+ }
97
+ await this.#options.deliver(job, result);
98
+ })()
99
+ .catch(() =>
100
+ this.#options.logger.error(
101
+ { channel: job.channel, job: job.id },
102
+ "Sandbox task report failed",
103
+ ),
104
+ )
105
+ .finally(() => this.#jobs.delete(job.id));
106
+ this.#jobs.set(job.id, { controller, channel: job.channel, done });
107
+ return job;
108
+ }
109
+ runningChannels(): ChannelKey[] {
110
+ return [...this.#jobs.values()].map((job) => job.channel);
111
+ }
112
+ async idle(): Promise<void> {
113
+ await Promise.all([...this.#jobs.values()].map((job) => job.done));
114
+ }
115
+ async dispose(): Promise<void> {
116
+ this.#closed = true;
117
+ for (const job of this.#jobs.values()) job.controller.abort();
118
+ await this.idle();
119
+ }
120
+ }
@@ -0,0 +1,4 @@
1
+ # Build context is the installed node_modules directory (no host credentials).
2
+ FROM oven/bun:1.4.2
3
+ WORKDIR /app
4
+ COPY --chown=0:0 . /app/node_modules
@@ -0,0 +1,12 @@
1
+ # Build with the application's release directory as context.
2
+ # DEPS_IMAGE must contain /app/node_modules, including the optional Pi SDK and bridge.
3
+ ARG DEPS_IMAGE
4
+ FROM ${DEPS_IMAGE}
5
+ WORKDIR /app
6
+ # Trusted content and explicit app worker extensions, not host source or credentials.
7
+ COPY --chown=0:0 assets /app/assets
8
+ COPY --chown=0:0 persona /app/persona
9
+ COPY --chown=0:0 shared /app/shared
10
+ COPY --chown=0:0 worker /app/worker
11
+ # Generic runtime comes from the installed official package in the dependency layer.
12
+ CMD ["bun", "/app/node_modules/pi-roundtable-sandbox/worker/pi-main.ts"]
@@ -0,0 +1,16 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { PiTurnContext } from "../src/pi-protocol.ts";
3
+ import type { PiWorkerToolSpec } from "./pi-tools.ts";
4
+
5
+ /** Trusted operator code baked into the image, never selected by guest input. */
6
+ export interface PiWorkerContent {
7
+ model: { provider: string; id: string };
8
+ prompt?: readonly string[];
9
+ skillsDir?: string;
10
+ brokerTools?: readonly PiWorkerToolSpec[];
11
+ /** Explicit local tool allow-list. Built-in shell/read/write/edit remain disabled. */
12
+ toolNames?: readonly string[];
13
+ extensions?: (
14
+ turn: () => PiTurnContext,
15
+ ) => { name: string; factory: ExtensionFactory }[];
16
+ }