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,382 @@
1
+ /**
2
+ * Opt-in Pi worker: one session per isolated channel, no network or host credentials.
3
+ */
4
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ type AgentSession,
8
+ createAgentSession,
9
+ DefaultResourceLoader,
10
+ ModelRuntime,
11
+ SessionManager,
12
+ SettingsManager,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ activeToolsExtension,
16
+ lastAssistant,
17
+ mcpAdapterExtension,
18
+ packageDir,
19
+ readAttachmentExtension,
20
+ textOf,
21
+ } from "pi-roundtable/kit";
22
+ import {
23
+ PI_BROKER_SOCKET as BROKER_SOCKET,
24
+ PI_RUN_DIR as CONTAINER_RUN_DIR,
25
+ PI_WORKSPACE as CONTAINER_WORKSPACE,
26
+ PI_FORWARDER_PORT as FORWARDER_PORT,
27
+ isPiThinkingLevel as isPartyThinkingLevel,
28
+ type PiMcpDiscovery as McpToolsResponse,
29
+ type PiTurnRequest as PartyTurnRequest,
30
+ type PiTurnResponse as PartyTurnResponse,
31
+ PI_MEDIA_LIMITS,
32
+ safeFileName,
33
+ type PiTurnContext as TurnContext,
34
+ validatePiTurn,
35
+ PI_ATTACHMENTS as WORKSPACE_ATTACHMENTS,
36
+ PI_OUTBOX as WORKSPACE_OUTBOX,
37
+ } from "../src/pi-protocol.ts";
38
+ import { boundedText } from "../src/protocol.ts";
39
+ import type { PiWorkerContent } from "./pi-content.ts";
40
+ import { speakerMemoryExtension } from "./pi-memory.ts";
41
+ import {
42
+ loadSkillIndex,
43
+ skillsExtension,
44
+ skillsPromptBlock,
45
+ } from "./pi-skills.ts";
46
+ import { boundedFile, brokerToolsExtension } from "./pi-tools.ts";
47
+
48
+ let content: PiWorkerContent;
49
+ let baseTools: string[] = [];
50
+ const THINKING = "low";
51
+ const MCP_CONNECT_TIMEOUT_MS = 45_000;
52
+ /** The tools every run may use: the base set plus the profile's MCP tools, fixed at startup. */
53
+ let activeTools: readonly string[] = baseTools;
54
+ /** The running turn, read by broker-backed tools; turns never overlap, the host queues them. */
55
+ const turnContext: TurnContext = {
56
+ authorId: "",
57
+ authorName: "",
58
+ outbox: "",
59
+ memory: "",
60
+ };
61
+ const TURN_TIMEOUT_MS = 10 * 60_000;
62
+ const DROPPED_HEADERS = [
63
+ "host",
64
+ "connection",
65
+ "content-length",
66
+ "accept-encoding",
67
+ ];
68
+ const DROPPED_RESPONSE_HEADERS = [
69
+ "content-encoding",
70
+ "content-length",
71
+ "transfer-encoding",
72
+ ];
73
+
74
+ function log(msg: string, fields: Record<string, unknown> = {}): void {
75
+ console.log(
76
+ JSON.stringify({ time: Date.now(), app: "sandbox-worker", msg, ...fields }),
77
+ );
78
+ }
79
+
80
+ function without(headers: Headers, names: readonly string[]): Headers {
81
+ const copy = new Headers(headers);
82
+ for (const name of names) copy.delete(name);
83
+ return copy;
84
+ }
85
+
86
+ /** Claude Code speaks only TCP, so loopback requests are forwarded to the host broker's socket. */
87
+ function startForwarder(): void {
88
+ const broker = join(CONTAINER_RUN_DIR, BROKER_SOCKET);
89
+ Bun.serve({
90
+ hostname: "127.0.0.1",
91
+ port: FORWARDER_PORT,
92
+ idleTimeout: 0,
93
+ async fetch(request) {
94
+ // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
95
+ const url = new URL(request.url);
96
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
97
+ const response = await fetch(
98
+ `http://broker${url.pathname}${url.search}`,
99
+ {
100
+ unix: broker,
101
+ method: request.method,
102
+ headers: without(request.headers, DROPPED_HEADERS),
103
+ body: hasBody
104
+ ? await boundedText(request.body, 96 * 1024 * 1024)
105
+ : undefined,
106
+ signal: request.signal,
107
+ },
108
+ );
109
+ return new Response(response.body, {
110
+ status: response.status,
111
+ statusText: response.statusText,
112
+ headers: without(response.headers, DROPPED_RESPONSE_HEADERS),
113
+ });
114
+ },
115
+ });
116
+ }
117
+
118
+ /** The profile's MCP servers and their tools, as the host broker resolved them. */
119
+ async function mcpServers(): Promise<McpToolsResponse["servers"]> {
120
+ // pi-lens-ignore: react-insecure-request -- it goes over the broker's Unix socket; "broker" names no host
121
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
122
+ const response = await fetch("http://broker/mcp-tools", {
123
+ unix: join(CONTAINER_RUN_DIR, BROKER_SOCKET),
124
+ });
125
+ if (!response.ok)
126
+ throw new Error(`the broker answered ${response.status} for mcp-tools`);
127
+ return ((await response.json()) as McpToolsResponse).servers;
128
+ }
129
+
130
+ /** pi-mcp-adapter registers tools only after its eager connection completes. */
131
+ async function waitForTools(
132
+ session: AgentSession,
133
+ expected: readonly string[],
134
+ ): Promise<void> {
135
+ const deadline = Date.now() + MCP_CONNECT_TIMEOUT_MS;
136
+ while (Date.now() < deadline) {
137
+ const registered = new Set(session.getAllTools().map((tool) => tool.name));
138
+ if (expected.every((name) => registered.has(name))) return;
139
+ await Bun.sleep(250);
140
+ }
141
+ }
142
+
143
+ async function createSession(
144
+ servers: McpToolsResponse["servers"],
145
+ ): Promise<AgentSession> {
146
+ const SKILLS = content.skillsDir ? loadSkillIndex(content.skillsDir) : [];
147
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? "/tmp/pi-agent";
148
+ mkdirSync(agentDir, { recursive: true });
149
+ const sessionDir = join(CONTAINER_WORKSPACE, "sessions");
150
+ mkdirSync(sessionDir, { recursive: true });
151
+ const modelRuntime = await ModelRuntime.create({
152
+ authPath: join(agentDir, "auth.json"),
153
+ });
154
+ const resourceLoader = new DefaultResourceLoader({
155
+ cwd: CONTAINER_WORKSPACE,
156
+ agentDir,
157
+ noExtensions: true,
158
+ noSkills: true,
159
+ noPromptTemplates: true,
160
+ noThemes: true,
161
+ noContextFiles: true,
162
+ additionalExtensionPaths: [packageDir("pi-claude-bridge", import.meta.url)],
163
+ extensionFactories: [
164
+ {
165
+ name: "read-attachment",
166
+ factory: readAttachmentExtension(
167
+ join(CONTAINER_WORKSPACE, WORKSPACE_ATTACHMENTS),
168
+ ),
169
+ },
170
+ {
171
+ name: "broker-tools",
172
+ factory: brokerToolsExtension(
173
+ join(CONTAINER_RUN_DIR, BROKER_SOCKET),
174
+ join(CONTAINER_WORKSPACE, WORKSPACE_ATTACHMENTS),
175
+ () => turnContext,
176
+ content.brokerTools ?? [],
177
+ ),
178
+ },
179
+ ...(content.skillsDir
180
+ ? [
181
+ {
182
+ name: "skills",
183
+ factory: skillsExtension(content.skillsDir, SKILLS),
184
+ },
185
+ ]
186
+ : []),
187
+ {
188
+ name: "speaker-memory",
189
+ factory: speakerMemoryExtension(() => turnContext),
190
+ },
191
+ ...(content.extensions?.(() => turnContext) ?? []),
192
+ ...(servers.length > 0
193
+ ? [
194
+ {
195
+ name: "mcp",
196
+ // Through the loopback forwarder; the broker adds the credential.
197
+ factory: mcpAdapterExtension(
198
+ servers.map((server) => ({
199
+ name: server.name,
200
+ url: `http://127.0.0.1:${FORWARDER_PORT}/mcp/${server.name}`,
201
+ })),
202
+ ),
203
+ },
204
+ ]
205
+ : []),
206
+ // Last, so it pins the tools after every other extension's before_agent_start handler.
207
+ {
208
+ name: "active-tools",
209
+ factory: activeToolsExtension(() => activeTools),
210
+ },
211
+ ],
212
+ appendSystemPrompt: [...(content.prompt ?? []), skillsPromptBlock(SKILLS)],
213
+ });
214
+ await resourceLoader.reload();
215
+ const { session } = await createAgentSession({
216
+ cwd: CONTAINER_WORKSPACE,
217
+ agentDir,
218
+ thinkingLevel: THINKING,
219
+ modelRuntime,
220
+ resourceLoader,
221
+ sessionManager: SessionManager.continueRecent(
222
+ CONTAINER_WORKSPACE,
223
+ sessionDir,
224
+ ),
225
+ settingsManager: SettingsManager.inMemory({}),
226
+ noTools: "builtin",
227
+ });
228
+ const model = modelRuntime.getModel(content.model.provider, content.model.id);
229
+ if (!model)
230
+ throw new Error(
231
+ `model ${content.model.provider}/${content.model.id} is not available`,
232
+ );
233
+ await session.setModel(model);
234
+ activeTools = [...baseTools, ...servers.flatMap((server) => server.tools)];
235
+ await waitForTools(session, activeTools);
236
+ const registered = new Set(session.getAllTools().map((tool) => tool.name));
237
+ const claimed = SKILLS.flatMap((skill) => skill.tools);
238
+ const missing = [...activeTools, ...claimed].filter(
239
+ (name) => !registered.has(name),
240
+ );
241
+ if (missing.length > 0)
242
+ throw new Error(`tools are not registered: ${missing.join(", ")}`);
243
+ return session;
244
+ }
245
+
246
+ async function runTurn(
247
+ session: AgentSession,
248
+ turn: PartyTurnRequest,
249
+ ): Promise<PartyTurnResponse> {
250
+ validatePiTurn(turn);
251
+ if (!isPartyThinkingLevel(turn.thinking))
252
+ return { ok: false, error: `unknown thinking level ${turn.thinking}` };
253
+ const outbox = join(CONTAINER_WORKSPACE, WORKSPACE_OUTBOX, turn.turnId);
254
+ mkdirSync(outbox, { recursive: true });
255
+ turnContext.authorId = turn.author.id;
256
+ turnContext.authorName = turn.author.name;
257
+ turnContext.outbox = outbox;
258
+ turnContext.memory = turn.memory;
259
+ session.setActiveToolsByName([...activeTools]);
260
+ session.setThinkingLevel(turn.thinking);
261
+ const timer = setTimeout(() => void session.abort(), TURN_TIMEOUT_MS);
262
+ const toolCalls: string[] = [];
263
+ const unsubscribe = session.subscribe((event) => {
264
+ if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
265
+ });
266
+ try {
267
+ await session.prompt(
268
+ `[${turn.author.name} (${turn.author.id})] ${turn.text}`,
269
+ turn.images.length > 0
270
+ ? {
271
+ images: turn.images.map((image) => ({
272
+ type: "image" as const,
273
+ ...image,
274
+ })),
275
+ }
276
+ : undefined,
277
+ );
278
+ } catch (error) {
279
+ return { ok: false, error: `prompt failed: ${String(error)}` };
280
+ } finally {
281
+ clearTimeout(timer);
282
+ unsubscribe();
283
+ log("turn finished", {
284
+ turnId: turn.turnId,
285
+ thinking: session.thinkingLevel,
286
+ toolCalls,
287
+ });
288
+ }
289
+ const last = lastAssistant(session.messages);
290
+ if (!last.ok) return last;
291
+ const text = textOf(last.message.content).trim();
292
+ if (!text || text.length > 100_000)
293
+ return { ok: false, error: "Final text bounds exceeded" };
294
+ const files = existsSync(outbox) ? readdirSync(outbox) : [];
295
+ if (
296
+ files.length > PI_MEDIA_LIMITS.files ||
297
+ files.some((name) => !safeFileName(name))
298
+ )
299
+ return { ok: false, error: "Output file bounds exceeded" };
300
+ if (files.length === 0) rmSync(outbox, { recursive: true, force: true });
301
+ let total = 0;
302
+ const replies = files.map((name) => {
303
+ const data = boundedFile(join(outbox, name), PI_MEDIA_LIMITS.fileBytes);
304
+ total += data.byteLength;
305
+ if (total > PI_MEDIA_LIMITS.totalFileBytes)
306
+ throw new Error("Reply byte budget exceeded");
307
+ return { name, data: Buffer.from(data).toString("base64") };
308
+ });
309
+ return { ok: true, text, files: replies };
310
+ }
311
+
312
+ async function main(): Promise<void> {
313
+ const profile = process.env.SANDBOX_PROFILE ?? "";
314
+ const contentModule = process.env.SANDBOX_CONTENT ?? "/app/worker/content.ts";
315
+ const loaded = (await import(contentModule)) as {
316
+ workerContent(profile: string): PiWorkerContent | Promise<PiWorkerContent>;
317
+ };
318
+ content = await loaded.workerContent(profile);
319
+ baseTools = [
320
+ "read_attachment",
321
+ ...(content.skillsDir ? ["read_skill"] : []),
322
+ ...(content.brokerTools ?? []).map((tool) => tool.name),
323
+ ...(content.toolNames ?? []),
324
+ ];
325
+ activeTools = baseTools;
326
+ mkdirSync(process.env.HOME ?? "/tmp/home", { recursive: true });
327
+
328
+ startForwarder();
329
+ const servers = await mcpServers();
330
+ const session = await createSession(servers);
331
+
332
+ const brokerSocket = join(CONTAINER_RUN_DIR, BROKER_SOCKET);
333
+ log("sandbox worker ready", { profile, tools: activeTools.length });
334
+ for (;;) {
335
+ try {
336
+ // Local Unix-socket transport; no TCP connection or DNS lookup is made.
337
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
338
+ const ready = await fetch("http://broker/worker/ready", {
339
+ unix: brokerSocket,
340
+ method: "POST",
341
+ signal: AbortSignal.timeout(5000),
342
+ });
343
+ await ready.body?.cancel();
344
+ // Local Unix-socket transport; no TCP connection or DNS lookup is made.
345
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
346
+ const next = await fetch("http://broker/worker/next", {
347
+ unix: brokerSocket,
348
+ signal: AbortSignal.timeout(55_000),
349
+ });
350
+ if (!next.ok) {
351
+ await next.body?.cancel();
352
+ await Bun.sleep(250);
353
+ continue;
354
+ }
355
+ const turn: unknown = JSON.parse(
356
+ await boundedText(next.body, 96 * 1024 * 1024),
357
+ );
358
+ validatePiTurn(turn);
359
+ const result = await runTurn(session, turn).catch(() => ({
360
+ ok: false as const,
361
+ error: "Worker output failed",
362
+ }));
363
+ // Local Unix-socket transport; no TCP connection or DNS lookup is made.
364
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
365
+ const sent = await fetch("http://broker/worker/result", {
366
+ unix: brokerSocket,
367
+ method: "POST",
368
+ headers: { "content-type": "application/json" },
369
+ body: JSON.stringify({ turnId: turn.turnId, result }),
370
+ signal: AbortSignal.timeout(60_000),
371
+ });
372
+ await sent.body?.cancel();
373
+ } catch {
374
+ await Bun.sleep(250);
375
+ }
376
+ }
377
+ }
378
+
379
+ main().catch((error: unknown) => {
380
+ log("sandbox worker failed", { error: String(error) });
381
+ process.exit(1);
382
+ });
@@ -0,0 +1,21 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { PiTurnContext } from "../src/pi-protocol.ts";
3
+
4
+ /**
5
+ * Adds what the channel keeps about the current speaker to this run's system prompt. The
6
+ * prompt is rebuilt every run, so the session history never piles up stale copies.
7
+ */
8
+ export function speakerMemoryExtension(
9
+ turn: () => PiTurnContext,
10
+ ): ExtensionFactory {
11
+ return (pi) => {
12
+ pi.on("before_agent_start", (event) => {
13
+ const { memory } = turn();
14
+ if (!memory) return;
15
+ const current = event.systemPromptOptions.appendSystemPrompt;
16
+ event.systemPromptOptions.appendSystemPrompt = current
17
+ ? `${current}\n\n${memory}`
18
+ : memory;
19
+ });
20
+ };
21
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ statSync,
7
+ } from "node:fs";
8
+ import { join, resolve, sep } from "node:path";
9
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
10
+ import { toolText } from "pi-roundtable/kit";
11
+ import { Type } from "typebox";
12
+
13
+ export interface SkillEntry {
14
+ name: string;
15
+ description: string;
16
+ /** Tools that run only after this skill is read in the session, from frontmatter `tools`. */
17
+ tools: string[];
18
+ }
19
+
20
+ const MAX_READ_CHARS = 40_000;
21
+
22
+ function frontmatter(text: string): Record<string, unknown> {
23
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
24
+ if (!match?.[1]) return {};
25
+ const parsed = Bun.YAML.parse(match[1]);
26
+ return parsed && typeof parsed === "object"
27
+ ? (parsed as Record<string, unknown>)
28
+ : {};
29
+ }
30
+
31
+ /** Each `<dir>/<skill>/SKILL.md` with a name and description; a skill without them fails startup. */
32
+ export function loadSkillIndex(dir: string): SkillEntry[] {
33
+ if (!existsSync(dir)) return [];
34
+ return readdirSync(dir, { withFileTypes: true })
35
+ .filter(
36
+ (entry) =>
37
+ entry.isDirectory() && existsSync(join(dir, entry.name, "SKILL.md")),
38
+ )
39
+ .map((entry) => {
40
+ const meta = frontmatter(
41
+ readFileSync(join(dir, entry.name, "SKILL.md"), "utf8"),
42
+ );
43
+ if (
44
+ meta.name !== entry.name ||
45
+ typeof meta.description !== "string" ||
46
+ !meta.description
47
+ ) {
48
+ throw new Error(
49
+ `skill ${entry.name} needs a matching name and a description`,
50
+ );
51
+ }
52
+ const tools = Array.isArray(meta.tools)
53
+ ? meta.tools.filter((tool): tool is string => typeof tool === "string")
54
+ : [];
55
+ return { name: entry.name, description: meta.description, tools };
56
+ })
57
+ .sort((a, b) => a.name.localeCompare(b.name));
58
+ }
59
+
60
+ /** The skills list for the system prompt, in the shape Pi uses for its own skills. */
61
+ export function skillsPromptBlock(skills: readonly SkillEntry[]): string {
62
+ if (skills.length === 0) return "";
63
+ const entries = skills.map(
64
+ (skill) =>
65
+ ` <skill>\n <name>${skill.name}</name>\n <description>${skill.description}</description>\n </skill>`,
66
+ );
67
+ return [
68
+ "These skills hold this channel's own procedures, which differ from general knowledge: which tools to call, what to record, what to ask first. When a request matches a skill's description, read it with read_skill before you answer or call any other tool, even on a subject you know well, unless you already read it in this conversation; load the files it points to the same way.",
69
+ "<available_skills>",
70
+ ...entries,
71
+ "</available_skills>",
72
+ ].join("\n");
73
+ }
74
+
75
+ /**
76
+ * `read_skill` reads only files inside the skills directory. A tool a skill claims is blocked
77
+ * until that skill's SKILL.md is read in the session, because the model skips skills whose
78
+ * subject it thinks it knows, and the skill holds this channel's own procedure.
79
+ * The built-in read tool stays off,
80
+ * because it could also read the channel's memory and session files.
81
+ */
82
+ export function skillsExtension(
83
+ dir: string,
84
+ skills: readonly SkillEntry[],
85
+ ): ExtensionFactory {
86
+ const known = new Set(skills.map((skill) => skill.name));
87
+ const owners = new Map(
88
+ skills.flatMap((skill) => skill.tools.map((tool) => [tool, skill.name])),
89
+ );
90
+ const read = new Set<string>();
91
+ return (pi) => {
92
+ pi.on("tool_call", (event) => {
93
+ const owner = owners.get(event.toolName);
94
+ return owner && !read.has(owner)
95
+ ? {
96
+ block: true,
97
+ reason: `Read the ${owner} skill with read_skill first; it sets how ${event.toolName} is used here.`,
98
+ }
99
+ : undefined;
100
+ });
101
+
102
+ pi.registerTool({
103
+ name: "read_skill",
104
+ label: "Read skill",
105
+ description:
106
+ "Load a skill's instructions (SKILL.md) or a file it references, such as ref/major-symbols.md.",
107
+ parameters: Type.Object({
108
+ skill: Type.String({
109
+ description: "Skill name from the available skills.",
110
+ }),
111
+ path: Type.Optional(
112
+ Type.String({
113
+ description: "File inside the skill; default SKILL.md.",
114
+ }),
115
+ ),
116
+ }),
117
+ execute: async (_toolCallId, params) => {
118
+ if (!known.has(params.skill))
119
+ throw new Error(`there is no skill named ${params.skill}`);
120
+ const base = realpathSync(dir);
121
+ const root = realpathSync(resolve(base, params.skill));
122
+ if (!root.startsWith(`${base}${sep}`))
123
+ throw new Error("Skill directory escapes the content tree");
124
+ const file = realpathSync(resolve(root, params.path ?? "SKILL.md"));
125
+ if (!file.startsWith(`${root}${sep}`) || !existsSync(file)) {
126
+ throw new Error(`${params.path} is not a file of ${params.skill}`);
127
+ }
128
+ if (statSync(file).size > 256 * 1024)
129
+ throw new Error("Skill file too large");
130
+ const text = readFileSync(file, "utf8");
131
+ if (file === join(root, "SKILL.md")) read.add(params.skill);
132
+ return toolText(
133
+ text.length > MAX_READ_CHARS
134
+ ? `${text.slice(0, MAX_READ_CHARS)}\n\n[truncated at ${MAX_READ_CHARS} characters]`
135
+ : text,
136
+ );
137
+ },
138
+ });
139
+ };
140
+ }