pi-roundtable-sandbox 0.7.0

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/src/broker.ts ADDED
@@ -0,0 +1,387 @@
1
+ import { chmodSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import type { Socket } from "node:net";
4
+ import { Readable } from "node:stream";
5
+ import type { ChannelKey } from "pi-roundtable";
6
+ import { modelInput } from "./model-input.ts";
7
+ import {
8
+ boundedText,
9
+ DUMMY_KEY,
10
+ isRecord,
11
+ type ToolSpec,
12
+ validName,
13
+ } from "./protocol.ts";
14
+
15
+ export interface BrokerListener {
16
+ stop(force?: boolean): Promise<void>;
17
+ }
18
+
19
+ export interface HostToolContext {
20
+ channel: ChannelKey;
21
+ speaker: { id: string; name: string };
22
+ signal: AbortSignal;
23
+ }
24
+ export interface HostTool extends ToolSpec {
25
+ run(
26
+ input: Record<string, unknown>,
27
+ context: HostToolContext,
28
+ ): Promise<string> | string;
29
+ }
30
+ export interface McpServer {
31
+ name: string;
32
+ /** A trusted, fixed stateless Streamable HTTP endpoint returning JSON. */
33
+ url: string;
34
+ apiKey?: () => Promise<string | undefined> | string | undefined;
35
+ tools: ToolSpec[];
36
+ }
37
+ export interface BrokerOptions {
38
+ context: HostToolContext;
39
+ model: string;
40
+ /** Fixed complete URL, not a guest-selected base URL. */
41
+ modelUrl: string;
42
+ apiKey: () => Promise<string | undefined> | string | undefined;
43
+ tools?: readonly HostTool[];
44
+ mcp?: readonly McpServer[];
45
+ /** Allow cleartext endpoints only for local testing. */
46
+ allowHttp?: boolean;
47
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
48
+ maxCalls?: number;
49
+ maxOutputTokens?: number;
50
+ }
51
+
52
+ const FORBIDDEN_HEADERS = new Set([
53
+ "connection",
54
+ "keep-alive",
55
+ "proxy-authenticate",
56
+ "proxy-authorization",
57
+ "te",
58
+ "trailer",
59
+ "transfer-encoding",
60
+ "upgrade",
61
+ "x-api-key",
62
+ "cookie",
63
+ "forwarded",
64
+ "x-forwarded-host",
65
+ "x-forwarded-for",
66
+ "x-forwarded-proto",
67
+ ]);
68
+
69
+ function checkUrl(raw: string, allowHttp: boolean): void {
70
+ let url: URL;
71
+ try {
72
+ url = new URL(raw);
73
+ } catch {
74
+ throw new Error("invalid broker endpoint URL");
75
+ }
76
+ if (
77
+ (!allowHttp && url.protocol !== "https:") ||
78
+ (allowHttp && !["http:", "https:"].includes(url.protocol)) ||
79
+ url.username ||
80
+ url.password ||
81
+ url.hash
82
+ )
83
+ throw new Error("broker endpoints must be credential-free HTTPS URLs");
84
+ }
85
+
86
+ /** A per-turn capability boundary: the host, not the guest, supplies the channel and speaker. */
87
+ export class SandboxBroker {
88
+ readonly #options: BrokerOptions;
89
+ readonly #tools: Map<string, HostTool>;
90
+ readonly #mcp: Map<string, McpServer>;
91
+ #remaining: number;
92
+ #active = false;
93
+
94
+ constructor(options: BrokerOptions) {
95
+ checkUrl(options.modelUrl, options.allowHttp ?? false);
96
+ this.#options = options;
97
+ this.#remaining = options.maxCalls ?? 48;
98
+ if (
99
+ !Number.isSafeInteger(this.#remaining) ||
100
+ this.#remaining < 1 ||
101
+ this.#remaining > 1000 ||
102
+ !Number.isSafeInteger(options.maxOutputTokens ?? 4096) ||
103
+ (options.maxOutputTokens ?? 4096) < 1 ||
104
+ (options.maxOutputTokens ?? 4096) > 32768
105
+ )
106
+ throw new Error("invalid broker call or output-token budget");
107
+ this.#tools = new Map();
108
+ this.#mcp = new Map();
109
+ for (const tool of options.tools ?? []) {
110
+ if (!validName(tool.name) || this.#tools.has(tool.name))
111
+ throw new Error("invalid or duplicate host tool");
112
+ this.#tools.set(tool.name, tool);
113
+ }
114
+ for (const server of options.mcp ?? []) {
115
+ checkUrl(server.url, options.allowHttp ?? false);
116
+ if (
117
+ !validName(server.name) ||
118
+ this.#mcp.has(server.name) ||
119
+ new Set(server.tools.map((tool) => tool.name)).size !==
120
+ server.tools.length ||
121
+ server.tools.some((tool) => !validName(tool.name))
122
+ )
123
+ throw new Error("invalid or duplicate MCP server/tool");
124
+ this.#mcp.set(server.name, server);
125
+ }
126
+ }
127
+
128
+ /** This handler can be exercised offline, without binding a socket or using Docker. */
129
+ async handle(request: Request): Promise<Response> {
130
+ let url: URL;
131
+ try {
132
+ url = new URL(request.url);
133
+ } catch {
134
+ return new Response("invalid URL", { status: 400 });
135
+ }
136
+ if (request.method !== "POST" || url.search || url.hash)
137
+ return new Response("not found", { status: 404 });
138
+ const permittedHeaders = new Set([
139
+ "host",
140
+ "content-type",
141
+ "content-length",
142
+ "accept",
143
+ "accept-encoding",
144
+ "authorization",
145
+ "user-agent",
146
+ ]);
147
+ for (const [name] of request.headers) {
148
+ if (FORBIDDEN_HEADERS.has(name) || !permittedHeaders.has(name))
149
+ return new Response("forbidden header", { status: 403 });
150
+ }
151
+ const auth = request.headers.get("authorization");
152
+ if (auth !== null && auth !== `Bearer ${DUMMY_KEY}`)
153
+ return new Response("forbidden credential", { status: 403 });
154
+ const host = request.headers.get("host");
155
+ if (host !== null && host !== "broker")
156
+ return new Response("forbidden host", { status: 403 });
157
+ const tool = url.pathname.startsWith("/tools/")
158
+ ? this.#tools.get(url.pathname.slice(7))
159
+ : undefined;
160
+ const server = url.pathname.startsWith("/mcp/")
161
+ ? this.#mcp.get(url.pathname.slice(5))
162
+ : undefined;
163
+ if (url.pathname !== "/model" && !tool && !server)
164
+ return new Response("not found", { status: 404 });
165
+ if (this.#options.context.signal.aborted)
166
+ return new Response("turn ended", { status: 410 });
167
+ if (this.#active || this.#remaining <= 0)
168
+ return new Response("call budget exhausted or busy", { status: 429 });
169
+ this.#active = true;
170
+ this.#remaining--;
171
+ const signal = AbortSignal.any([
172
+ this.#options.context.signal,
173
+ request.signal,
174
+ AbortSignal.timeout(60_000),
175
+ ]);
176
+ try {
177
+ const body: unknown = JSON.parse(
178
+ await boundedText(request.body, 256 * 1024),
179
+ );
180
+ if (!isRecord(body))
181
+ return new Response("invalid input", { status: 400 });
182
+ signal.throwIfAborted();
183
+ if (tool) {
184
+ const result = await tool.run(body, {
185
+ ...this.#options.context,
186
+ signal,
187
+ });
188
+ if (
189
+ typeof result !== "string" ||
190
+ Buffer.byteLength(JSON.stringify({ text: result })) > 1024 * 1024
191
+ )
192
+ throw new Error("invalid tool result");
193
+ return Response.json({ text: result });
194
+ }
195
+ if (server) {
196
+ const params = body.params;
197
+ if (
198
+ body.method !== "tools/call" ||
199
+ !isRecord(params) ||
200
+ typeof params.name !== "string" ||
201
+ !server.tools.some((entry) => entry.name === params.name) ||
202
+ !isRecord(params.arguments)
203
+ )
204
+ return new Response("MCP method or tool refused", { status: 403 });
205
+ return await this.#forward(
206
+ server.url,
207
+ await server.apiKey?.(),
208
+ {
209
+ jsonrpc: "2.0",
210
+ id: 1,
211
+ method: "tools/call",
212
+ params: { name: params.name, arguments: params.arguments },
213
+ },
214
+ signal,
215
+ );
216
+ }
217
+ const normalized = modelInput(body);
218
+ if (!normalized)
219
+ return new Response("text/function model payload required", {
220
+ status: 400,
221
+ });
222
+ const key = await this.#options.apiKey();
223
+ if (!key) throw new Error("model credential unavailable");
224
+ return await this.#forward(
225
+ this.#options.modelUrl,
226
+ key,
227
+ {
228
+ model: this.#options.model,
229
+ ...normalized,
230
+ stream: false,
231
+ max_tokens: this.#options.maxOutputTokens ?? 4096,
232
+ },
233
+ signal,
234
+ );
235
+ } catch {
236
+ // Never expose errors containing upstream URLs, headers, credentials or tool internals.
237
+ return new Response("broker call failed", { status: 502 });
238
+ } finally {
239
+ this.#active = false;
240
+ }
241
+ }
242
+
243
+ async #forward(
244
+ url: string,
245
+ key: string | undefined,
246
+ body: Record<string, unknown>,
247
+ signal: AbortSignal,
248
+ ): Promise<Response> {
249
+ const response = await (this.#options.fetchImpl ?? fetch)(url, {
250
+ method: "POST",
251
+ redirect: "error",
252
+ headers: {
253
+ "content-type": "application/json",
254
+ accept: "application/json",
255
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
256
+ },
257
+ body: JSON.stringify(body),
258
+ signal,
259
+ });
260
+ if (!response.ok) throw new Error("upstream refused");
261
+ const text = await boundedText(response.body, 1024 * 1024);
262
+ // Only JSON bodies leave the broker; no upstream headers, cookies or redirects do.
263
+ // A trusted upstream must not reflect secrets in its data either.
264
+ let payload: unknown;
265
+ try {
266
+ payload = JSON.parse(text);
267
+ } catch {
268
+ throw new Error("upstream returned invalid JSON");
269
+ }
270
+ const secrets = key
271
+ ? [
272
+ key,
273
+ encodeURIComponent(key),
274
+ Buffer.from(key).toString("base64"),
275
+ Buffer.from(key).toString("base64url"),
276
+ Buffer.from(key).toString("hex"),
277
+ Buffer.from(key).toString("hex").toUpperCase(),
278
+ ]
279
+ : [];
280
+ const reflected = (value: unknown): boolean => {
281
+ if (typeof value === "string")
282
+ return secrets.some((secret) => value.includes(secret));
283
+ if (Array.isArray(value)) return value.some(reflected);
284
+ if (isRecord(value))
285
+ return Object.entries(value).some(
286
+ ([name, entry]) => reflected(name) || reflected(entry),
287
+ );
288
+ return false;
289
+ };
290
+ if (reflected(payload)) throw new Error("credential reflected");
291
+ return Response.json(payload);
292
+ }
293
+
294
+ async listen(socketPath: string): Promise<BrokerListener> {
295
+ const sockets = new Set<Socket>();
296
+ const headerTimers = new Map<Socket, ReturnType<typeof setTimeout>>();
297
+ const server = createServer(async (incoming, outgoing) => {
298
+ clearTimeout(headerTimers.get(incoming.socket));
299
+ headerTimers.delete(incoming.socket);
300
+ const requestController = new AbortController();
301
+ const requestTimer = setTimeout(() => {
302
+ requestController.abort();
303
+ incoming.destroy();
304
+ outgoing.destroy();
305
+ }, 60_000);
306
+ const finish = () => {
307
+ clearTimeout(requestTimer);
308
+ requestController.abort();
309
+ };
310
+ outgoing.once("finish", finish);
311
+ outgoing.once("close", finish);
312
+ outgoing.on("error", () => outgoing.destroy());
313
+ outgoing.setHeader("connection", "close");
314
+ try {
315
+ const headers = new Headers();
316
+ for (const [name, value] of Object.entries(incoming.headers)) {
317
+ if (value !== undefined)
318
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
319
+ }
320
+ const request = new Request(`http://broker${incoming.url ?? "/"}`, {
321
+ method: incoming.method ?? "GET",
322
+ headers,
323
+ signal: requestController.signal,
324
+ ...(incoming.method === "GET" || incoming.method === "HEAD"
325
+ ? {}
326
+ : { body: Readable.toWeb(incoming), duplex: "half" }),
327
+ });
328
+ const response = await this.handle(request);
329
+ const bytes = Buffer.from(await response.arrayBuffer());
330
+ if (!outgoing.destroyed && !outgoing.writableEnded) {
331
+ outgoing.writeHead(
332
+ response.status,
333
+ Object.fromEntries(response.headers),
334
+ );
335
+ outgoing.end(bytes);
336
+ }
337
+ } catch {
338
+ if (!outgoing.destroyed && !outgoing.writableEnded) {
339
+ if (!outgoing.headersSent) outgoing.writeHead(400);
340
+ outgoing.end("bad request");
341
+ }
342
+ }
343
+ });
344
+ server.on("connection", (socket) => {
345
+ if (sockets.size >= 16) {
346
+ socket.destroy();
347
+ return;
348
+ }
349
+ sockets.add(socket);
350
+ headerTimers.set(
351
+ socket,
352
+ setTimeout(() => socket.destroy(), 10_000),
353
+ );
354
+ socket.once("close", () => {
355
+ sockets.delete(socket);
356
+ clearTimeout(headerTimers.get(socket));
357
+ headerTimers.delete(socket);
358
+ });
359
+ });
360
+ server.maxConnections = 16;
361
+ server.headersTimeout = 10_000;
362
+ server.requestTimeout = 60_000;
363
+ server.keepAliveTimeout = 1000;
364
+ await new Promise<void>((resolve, reject) => {
365
+ server.once("error", reject);
366
+ server.listen(socketPath, resolve);
367
+ });
368
+ try {
369
+ chmodSync(socketPath, 0o600);
370
+ } catch (error) {
371
+ for (const socket of sockets) socket.destroy();
372
+ server.closeAllConnections();
373
+ await new Promise<void>((resolve) => server.close(() => resolve()));
374
+ throw error;
375
+ }
376
+ return {
377
+ stop: (force = true) =>
378
+ new Promise<void>((resolve) => {
379
+ if (force) {
380
+ for (const socket of sockets) socket.destroy();
381
+ server.closeAllConnections();
382
+ }
383
+ server.close(() => resolve());
384
+ }),
385
+ };
386
+ }
387
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import type { ChannelKey } from "pi-roundtable";
12
+
13
+ /** Host-only routing state. The file is never mounted into a worker. Invalid state fails closed. */
14
+ export class SandboxChannelStore {
15
+ readonly #path: string;
16
+ readonly #channels: Set<ChannelKey>;
17
+ constructor(path: string, initial: readonly ChannelKey[] = []) {
18
+ this.#path = path;
19
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
20
+ const parent = statSync(dirname(path));
21
+ if (parent.uid !== process.getuid?.() || (parent.mode & 0o077) !== 0)
22
+ throw new Error(
23
+ "routing state directory must be private and service-owned",
24
+ );
25
+ if (
26
+ existsSync(path) &&
27
+ (!lstatSync(path).isFile() || lstatSync(path).uid !== process.getuid?.())
28
+ )
29
+ throw new Error("routing state must be a service-owned regular file");
30
+ let raw: unknown = [...initial];
31
+ if (existsSync(path)) {
32
+ try {
33
+ raw = JSON.parse(readFileSync(path, "utf8"));
34
+ } catch {
35
+ throw new Error("invalid sandbox channel state");
36
+ }
37
+ }
38
+ if (
39
+ !Array.isArray(raw) ||
40
+ raw.some(
41
+ (key) => typeof key !== "string" || !/^[a-z][a-z0-9_-]*:.+$/.test(key),
42
+ )
43
+ )
44
+ throw new Error("invalid sandbox channel state");
45
+ this.#channels = new Set(raw as ChannelKey[]);
46
+ if (!existsSync(path)) this.#save();
47
+ }
48
+ has(channel: ChannelKey): boolean {
49
+ return this.#channels.has(channel);
50
+ }
51
+ list(): ChannelKey[] {
52
+ return [...this.#channels];
53
+ }
54
+ enable(channel: ChannelKey): void {
55
+ if (!/^[a-z][a-z0-9_-]*:.+$/.test(channel))
56
+ throw new Error("invalid channel key");
57
+ const existed = this.#channels.has(channel);
58
+ this.#channels.add(channel);
59
+ try {
60
+ this.#save();
61
+ } catch (error) {
62
+ if (!existed) this.#channels.delete(channel);
63
+ throw error;
64
+ }
65
+ }
66
+ disable(channel: ChannelKey): void {
67
+ const existed = this.#channels.delete(channel);
68
+ try {
69
+ this.#save();
70
+ } catch (error) {
71
+ if (existed) this.#channels.add(channel);
72
+ throw error;
73
+ }
74
+ }
75
+ #save(): void {
76
+ writeFileSync(`${this.#path}.tmp`, JSON.stringify([...this.#channels]), {
77
+ mode: 0o600,
78
+ });
79
+ renameSync(`${this.#path}.tmp`, this.#path);
80
+ }
81
+ }
package/src/claim.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type {
2
+ ChannelClaim,
3
+ ChannelKey,
4
+ InboundMessage,
5
+ SurfacePort,
6
+ } from "pi-roundtable";
7
+ import { splitReply } from "pi-roundtable/kit";
8
+ import type { SandboxChannelStore } from "./channel-store.ts";
9
+ import type { SandboxRuntime } from "./runtime.ts";
10
+
11
+ export interface SandboxClaimOptions {
12
+ channels: Pick<SandboxChannelStore, "has">;
13
+ runtime: Pick<SandboxRuntime, "runTurn" | "stop" | "startFresh">;
14
+ surfaces: Pick<SurfacePort, "sendReply" | "startTyping">;
15
+ reportFailure?: (channel: ChannelKey) => void;
16
+ }
17
+
18
+ export function isSandboxAddress(message: InboundMessage): boolean {
19
+ return (
20
+ !message.authorIsBot &&
21
+ !message.integration &&
22
+ !message.isDirect &&
23
+ (message.mentionsBot || message.repliesToBot)
24
+ );
25
+ }
26
+
27
+ /** Outranks the agent server's priority 100; ignored messages never fall through to host agents. */
28
+ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
29
+ return {
30
+ name: "sandbox",
31
+ priority: 200,
32
+ postsInPlace: true,
33
+ owns: (channel) => options.channels.has(channel),
34
+ admit: (message) => {
35
+ if (!options.channels.has(message.channel) || !isSandboxAddress(message))
36
+ return undefined;
37
+ return {
38
+ kind: "turn",
39
+ failure: "sandbox turn failed",
40
+ run: async () => {
41
+ // A queued turn rechecks mode, so an off command cannot send it to host tools.
42
+ if (!options.channels.has(message.channel)) return;
43
+ const stopTyping = options.surfaces.startTyping(message.channel);
44
+ try {
45
+ const text = `${message.reference?.text ? `Quoted message (untrusted): ${message.reference.text}\n\n` : ""}${message.text}${message.attachments.length ? "\n[Attachments are not supported in this sandbox.]" : ""}`;
46
+ const reply = await options.runtime.runTurn(
47
+ message.channel,
48
+ { id: message.authorId, name: message.authorName },
49
+ text,
50
+ );
51
+ await options.surfaces.sendReply(message.channel, {
52
+ chunks: splitReply(
53
+ reply.ok ? reply.text : "The sandbox turn failed.",
54
+ ),
55
+ });
56
+ } catch {
57
+ options.reportFailure?.(message.channel);
58
+ await options.surfaces.sendReply(message.channel, {
59
+ chunks: ["The sandbox turn failed or was stopped."],
60
+ });
61
+ } finally {
62
+ stopTyping();
63
+ }
64
+ },
65
+ };
66
+ },
67
+ background: async () => ({
68
+ status: "skipped",
69
+ reason: "Background host turns are refused in sandbox channels.",
70
+ }),
71
+ stop: (channel) => options.runtime.stop(channel),
72
+ startFresh: async (channel) => {
73
+ options.runtime.startFresh(channel);
74
+ return "sandbox";
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,63 @@
1
+ import type { QueuePort } from "pi-roundtable";
2
+ import {
3
+ type CommandGuard,
4
+ groupOption,
5
+ type InteractionContribution,
6
+ OwnerFacingError,
7
+ ownerCommandModule,
8
+ } from "pi-roundtable/discord";
9
+ import type { SandboxChannelStore } from "./channel-store.ts";
10
+
11
+ /** Owner-only /roundtable sandbox on|off|status; mode changes use the same channel queue as turns. */
12
+ export function sandboxCommands(
13
+ guard: CommandGuard,
14
+ channels: SandboxChannelStore,
15
+ queue: QueuePort,
16
+ ): InteractionContribution {
17
+ return {
18
+ rootOptions: [
19
+ groupOption((group) =>
20
+ group
21
+ .setName("sandbox")
22
+ .setDescription("Manage this channel's sealed guest agent")
23
+ .addSubcommand((sub) =>
24
+ sub
25
+ .setName("on")
26
+ .setDescription(
27
+ "Route mentions and replies into a sealed container",
28
+ ),
29
+ )
30
+ .addSubcommand((sub) =>
31
+ sub
32
+ .setName("off")
33
+ .setDescription("Disable sandbox routing; keep channel memory"),
34
+ )
35
+ .addSubcommand((sub) =>
36
+ sub
37
+ .setName("status")
38
+ .setDescription("Show whether this channel is sandboxed"),
39
+ ),
40
+ ),
41
+ ],
42
+ module: ownerCommandModule(guard, {
43
+ owns: (group) => group === "sandbox",
44
+ command: async (interaction) => {
45
+ if (!interaction.inGuild() || !interaction.channelId)
46
+ throw new OwnerFacingError(
47
+ "Sandbox commands require a guild channel.",
48
+ );
49
+ const channel = `discord:${interaction.channelId}` as const;
50
+ await queue.run(channel, async () => {
51
+ const action = interaction.options.getSubcommand(true);
52
+ if (action === "on") channels.enable(channel);
53
+ else if (action === "off") channels.disable(channel);
54
+ await interaction.editReply(
55
+ channels.has(channel)
56
+ ? "Sandbox routing is enabled. Mention the bot or reply to it to start a sealed turn."
57
+ : "Sandbox routing is disabled. Memory is retained; normal host routing applies again.",
58
+ );
59
+ });
60
+ },
61
+ }),
62
+ };
63
+ }