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,396 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ lstatSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readdirSync,
8
+ readSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeSync,
15
+ } from "node:fs";
16
+ import { isAbsolute, join } from "node:path";
17
+ import { AgentRunError, type ChannelKey, type Logger } from "pi-roundtable";
18
+ import { channelSegment } from "pi-roundtable/kit";
19
+ import { ownDirectory } from "./directory-file.ts";
20
+ import { type PiBrokerOptions, PiSandboxBroker } from "./pi-broker.ts";
21
+ import {
22
+ type PiContainerDriver,
23
+ type PiContainerStatus,
24
+ PiDockerContainerDriver,
25
+ } from "./pi-container-driver.ts";
26
+ import {
27
+ PI_BROKER_SOCKET,
28
+ type PiImage,
29
+ type PiThinkingLevel,
30
+ type PiTurnRequest,
31
+ safeFileName,
32
+ validateImages,
33
+ validateReplyFiles,
34
+ } from "./pi-protocol.ts";
35
+
36
+ /** Moves plain top-level `.jsonl` files into `archive/<time>/`, only after the container is gone. */
37
+ function archiveOwnSessions(dir: string, now = new Date()): number {
38
+ if (!lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return 0;
39
+ const files = readdirSync(dir).filter(
40
+ (file) => file.endsWith(".jsonl") && lstatSync(join(dir, file)).isFile(),
41
+ );
42
+ if (files.length === 0) return 0;
43
+ const archive = join(dir, "archive");
44
+ ownDirectory(archive);
45
+ const target = join(archive, now.toISOString().replaceAll(":", "-"));
46
+ ownDirectory(target);
47
+ for (const file of files) renameSync(join(dir, file), join(target, file));
48
+ return files.length;
49
+ }
50
+
51
+ function abortable<T>(signal: AbortSignal, promise: Promise<T>): Promise<T> {
52
+ return new Promise((resolve, reject) => {
53
+ const abort = () => reject(new Error("Operation cancelled"));
54
+ if (signal.aborted) {
55
+ abort();
56
+ return;
57
+ }
58
+ signal.addEventListener("abort", abort, { once: true });
59
+ promise
60
+ .then(resolve, reject)
61
+ .finally(() => signal.removeEventListener("abort", abort));
62
+ });
63
+ }
64
+
65
+ export interface PiProfile {
66
+ model: string;
67
+ mcp?: readonly string[];
68
+ }
69
+ export interface PiSandboxRuntimeOptions
70
+ extends Omit<PiBrokerOptions, "model" | "mcp"> {
71
+ /** Existing channelSegment(channel)/workspace/sessions layout is retained. */
72
+ partyDir: string;
73
+ image: string;
74
+ profiles: Readonly<Record<string, PiProfile>>;
75
+ driver?: PiContainerDriver;
76
+ mcp?: PiBrokerOptions["mcp"];
77
+ memory: {
78
+ promptBlock(channel: ChannelKey, id: string, name: string): Promise<string>;
79
+ };
80
+ effort: {
81
+ judge(
82
+ text: string,
83
+ previous: { level: PiThinkingLevel | undefined },
84
+ ): Promise<PiThinkingLevel>;
85
+ };
86
+ logger: Logger;
87
+ timeZone?: string;
88
+ containerPrefix?: string;
89
+ labelChannel?: string;
90
+ labelProfile?: string;
91
+ startTimeoutMs?: number;
92
+ turnTimeoutMs?: number;
93
+ }
94
+ export interface PiSandboxTurn {
95
+ channel: ChannelKey;
96
+ profile: string;
97
+ turnId: string;
98
+ author: { id: string; name: string };
99
+ text: string;
100
+ images: PiImage[];
101
+ signal?: AbortSignal;
102
+ }
103
+ export type PiSandboxTurnResult =
104
+ | { ok: true; text: string; files: { name: string; data: Uint8Array }[] }
105
+ | { ok: false; error: AgentRunError };
106
+ interface BrokerEntry {
107
+ profile: string;
108
+ broker: PiSandboxBroker;
109
+ stop(): Promise<void>;
110
+ }
111
+
112
+ /** Opt-in full Pi mode. Host state adapters are scoped, never mounted in the worker. */
113
+ export class PiSandboxRuntime {
114
+ readonly #options: PiSandboxRuntimeOptions;
115
+ readonly #driver: PiContainerDriver;
116
+ readonly #brokers = new Map<ChannelKey, BrokerEntry>();
117
+ readonly #starts = new Map<ChannelKey, Promise<void>>();
118
+ readonly #active = new Map<ChannelKey, AbortController>();
119
+ readonly #judged = new Map<ChannelKey, PiThinkingLevel>();
120
+ constructor(options: PiSandboxRuntimeOptions) {
121
+ if (!isAbsolute(options.partyDir) || options.partyDir === "/")
122
+ throw new Error("Dedicated absolute state directory required");
123
+ if (!options.driver && process.platform !== "linux")
124
+ throw new Error("Pi sandbox requires native Linux and local Docker");
125
+ if ((process.getuid?.() ?? 0) <= 0 || (process.getgid?.() ?? 0) <= 0)
126
+ throw new Error("Pi sandbox host must be non-root");
127
+ if (
128
+ !/^[a-z0-9][a-z0-9-]{0,40}$/.test(
129
+ options.containerPrefix ?? "roundtable-sandbox",
130
+ )
131
+ )
132
+ throw new Error("Invalid container prefix");
133
+ for (const timeout of [
134
+ options.turnTimeoutMs ?? 600_000,
135
+ options.startTimeoutMs ?? 90_000,
136
+ ])
137
+ if (!Number.isSafeInteger(timeout) || timeout < 1000 || timeout > 600_000)
138
+ throw new Error("Invalid runtime deadline");
139
+ mkdirSync(options.partyDir, { recursive: true, mode: 0o700 });
140
+ const info = statSync(options.partyDir);
141
+ if (info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0)
142
+ throw new Error("State directory must be private and host-owned");
143
+ this.#options = { ...options, partyDir: realpathSync(options.partyDir) };
144
+ this.#driver =
145
+ options.driver ??
146
+ new PiDockerContainerDriver(undefined, options.labelProfile);
147
+ }
148
+ #segment(channel: ChannelKey): string {
149
+ if (channel.length > 256) throw new Error("Channel key too long");
150
+ const segment = channelSegment(channel);
151
+ const root = join(this.#options.partyDir, segment);
152
+ mkdirSync(root, { recursive: true, mode: 0o700 });
153
+ if (realpathSync(root) !== root)
154
+ throw new Error("Channel root symlink refused");
155
+ const path = join(root, ".channel-key");
156
+ try {
157
+ const fd = openSync(
158
+ path,
159
+ constants.O_WRONLY |
160
+ constants.O_CREAT |
161
+ constants.O_EXCL |
162
+ constants.O_NOFOLLOW,
163
+ 0o600,
164
+ );
165
+ try {
166
+ const data = Buffer.from(channel);
167
+ let written = 0;
168
+ while (written < data.length)
169
+ written += writeSync(fd, data, written, data.length - written);
170
+ } finally {
171
+ closeSync(fd);
172
+ }
173
+ } catch (error) {
174
+ if (
175
+ !(error instanceof Error && "code" in error && error.code === "EEXIST")
176
+ )
177
+ throw error;
178
+ }
179
+ const fd = openSync(
180
+ path,
181
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
182
+ );
183
+ try {
184
+ const data = Buffer.alloc(1025);
185
+ const length = readSync(fd, data, 0, data.length, null);
186
+ if (data.subarray(0, length).toString() !== channel)
187
+ throw new Error("Channel workspace alias refused");
188
+ } finally {
189
+ closeSync(fd);
190
+ }
191
+ return segment;
192
+ }
193
+ #name(channel: ChannelKey): string {
194
+ return `${this.#options.containerPrefix ?? "roundtable-sandbox"}-${this.#segment(channel)}`;
195
+ }
196
+ #paths(channel: ChannelKey) {
197
+ const root = join(this.#options.partyDir, this.#segment(channel));
198
+ return { root, run: join(root, "run"), workspace: join(root, "workspace") };
199
+ }
200
+ attachmentDir(channel: ChannelKey): string {
201
+ return join(this.#paths(channel).workspace, "attachments");
202
+ }
203
+ sessionsDir(channel: ChannelKey): string {
204
+ return join(this.#paths(channel).workspace, "sessions");
205
+ }
206
+ status(channel: ChannelKey): Promise<PiContainerStatus> {
207
+ return this.#driver.status(this.#name(channel));
208
+ }
209
+ async start(channel: ChannelKey, profile: string): Promise<void> {
210
+ const pending = this.#starts.get(channel);
211
+ if (pending) {
212
+ await pending;
213
+ if (this.#brokers.get(channel)?.profile === profile) return;
214
+ }
215
+ const start = this.#start(channel, profile);
216
+ this.#starts.set(channel, start);
217
+ try {
218
+ await start;
219
+ } finally {
220
+ if (this.#starts.get(channel) === start) this.#starts.delete(channel);
221
+ }
222
+ }
223
+ async #start(channel: ChannelKey, profile: string): Promise<void> {
224
+ const selected = Object.hasOwn(this.#options.profiles, profile)
225
+ ? this.#options.profiles[profile]
226
+ : undefined;
227
+ if (!selected) throw new Error("Unknown profile");
228
+ const paths = this.#paths(channel);
229
+ // The guest owns what is inside the workspace, so a hostile entry there is replaced, not trusted.
230
+ for (const dir of [paths.root, paths.run, paths.workspace]) {
231
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
232
+ if (realpathSync(dir) !== dir)
233
+ throw new Error("Channel directory symlinks refused");
234
+ }
235
+ for (const dir of [
236
+ this.attachmentDir(channel),
237
+ join(paths.workspace, "outbox"),
238
+ this.sessionsDir(channel),
239
+ ]) {
240
+ ownDirectory(dir);
241
+ if (realpathSync(dir) !== dir)
242
+ throw new Error("Channel directory symlinks refused");
243
+ }
244
+ let current = this.#brokers.get(channel);
245
+ const state = await this.#driver.status(this.#name(channel));
246
+ const recreate =
247
+ state.state !== "running" ||
248
+ (state.image !== undefined && state.image !== this.#options.image) ||
249
+ (state.profile !== undefined && state.profile !== profile);
250
+ if (current?.profile !== profile || recreate) {
251
+ if (current && recreate) await this.#driver.remove(this.#name(channel));
252
+ await current?.stop();
253
+ const servers = (selected.mcp ?? []).map((name) => {
254
+ const server = this.#options.mcp?.servers.find((s) => s.name === name);
255
+ if (!server) throw new Error(`MCP server ${name} unavailable`);
256
+ return server;
257
+ });
258
+ const broker = new PiSandboxBroker({
259
+ ...this.#options,
260
+ model: selected.model,
261
+ mcp: this.#options.mcp ? { ...this.#options.mcp, servers } : undefined,
262
+ });
263
+ const socket = join(paths.run, PI_BROKER_SOCKET);
264
+ if (socket.length > 100) throw new Error("Broker socket path too long");
265
+ rmSync(socket, { force: true });
266
+ const listener = await broker.listen(socket);
267
+ current = { broker, profile, stop: () => listener.stop(true) };
268
+ this.#brokers.set(channel, current);
269
+ }
270
+ await this.#driver.ensureRunning({
271
+ name: this.#name(channel),
272
+ image: this.#options.image,
273
+ channel,
274
+ profile,
275
+ runDir: paths.run,
276
+ workspaceDir: paths.workspace,
277
+ uid: process.getuid?.() ?? 0,
278
+ gid: process.getgid?.() ?? 0,
279
+ timeZone: this.#options.timeZone,
280
+ labelChannel: this.#options.labelChannel,
281
+ labelProfile: this.#options.labelProfile,
282
+ });
283
+ const deadline = Date.now() + (this.#options.startTimeoutMs ?? 90_000);
284
+ while (Date.now() < deadline) {
285
+ this.#active.get(channel)?.signal.throwIfAborted();
286
+ if (current.broker.isReady()) return;
287
+ await Bun.sleep(100);
288
+ }
289
+ throw new Error("Worker did not become ready");
290
+ }
291
+ async stop(channel: ChannelKey): Promise<void> {
292
+ this.#active.get(channel)?.abort();
293
+ await this.#starts.get(channel)?.catch(() => {});
294
+ await this.#driver.remove(this.#name(channel));
295
+ await this.#brokers.get(channel)?.stop();
296
+ this.#brokers.delete(channel);
297
+ this.#judged.delete(channel);
298
+ }
299
+ async startFresh(channel: ChannelKey): Promise<void> {
300
+ await this.stop(channel);
301
+ const archived = archiveOwnSessions(this.sessionsDir(channel));
302
+ // Repair guest-planted entries now, so the next attachment collection does not fail on them.
303
+ ownDirectory(this.attachmentDir(channel));
304
+ this.#options.logger.info(
305
+ { channel, archived },
306
+ "Sandbox conversation archived",
307
+ );
308
+ }
309
+ async stopBrokers(): Promise<void> {
310
+ for (const controller of this.#active.values()) controller.abort();
311
+ await Promise.allSettled([...this.#starts.values()]);
312
+ await Promise.all([...this.#brokers.values()].map((entry) => entry.stop()));
313
+ this.#brokers.clear();
314
+ }
315
+ async runTurn(turn: PiSandboxTurn): Promise<PiSandboxTurnResult> {
316
+ if (this.#active.has(turn.channel))
317
+ return { ok: false, error: new AgentRunError("Channel is busy") };
318
+ const controller = new AbortController();
319
+ this.#active.set(turn.channel, controller);
320
+ const signal = AbortSignal.any([
321
+ controller.signal,
322
+ ...(turn.signal ? [turn.signal] : []),
323
+ AbortSignal.timeout(this.#options.turnTimeoutMs ?? 600_000),
324
+ ]);
325
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
326
+ let release: (() => void) | undefined;
327
+ try {
328
+ signal.throwIfAborted();
329
+ if (
330
+ !safeFileName(turn.turnId) ||
331
+ turn.text.length > 100_000 ||
332
+ turn.author.id.length > 256 ||
333
+ turn.author.name.length > 256
334
+ )
335
+ throw new Error("Invalid turn input");
336
+ validateImages(turn.images);
337
+ const [thinking] = await abortable(
338
+ signal,
339
+ Promise.all([
340
+ this.#options.effort.judge(turn.text, {
341
+ level: this.#judged.get(turn.channel),
342
+ }),
343
+ this.start(turn.channel, turn.profile),
344
+ ]),
345
+ );
346
+ signal.throwIfAborted();
347
+ this.#judged.set(turn.channel, thinking);
348
+ const memory = await abortable(
349
+ signal,
350
+ this.#options.memory.promptBlock(
351
+ turn.channel,
352
+ turn.author.id,
353
+ turn.author.name,
354
+ ),
355
+ );
356
+ if (memory.length > 100_000) throw new Error("Memory context too large");
357
+ const request: PiTurnRequest = {
358
+ turnId: turn.turnId,
359
+ author: { ...turn.author },
360
+ text: turn.text,
361
+ memory,
362
+ images: turn.images,
363
+ thinking,
364
+ };
365
+ const entry = this.#brokers.get(turn.channel);
366
+ if (!entry) throw new Error("Broker unavailable");
367
+ release = entry.broker.bind({
368
+ channel: turn.channel,
369
+ profile: turn.profile,
370
+ speaker: turn.author,
371
+ thinking,
372
+ signal,
373
+ });
374
+ const body = await entry.broker.execute(request, signal);
375
+ signal.throwIfAborted();
376
+ if (!body.ok) throw new Error("Worker turn failed");
377
+ validateReplyFiles(body.files);
378
+ const files = body.files.map((file) => ({
379
+ name: file.name,
380
+ data: Buffer.from(file.data, "base64"),
381
+ }));
382
+ return { ok: true, text: body.text, files };
383
+ } catch {
384
+ // A timed-out Pi session must not keep consuming broker tools or corrupt the next turn.
385
+ if (signal.aborted) {
386
+ await this.#starts.get(turn.channel)?.catch(() => {});
387
+ await this.#driver.remove(this.#name(turn.channel));
388
+ }
389
+ return { ok: false, error: new AgentRunError("Sandbox turn failed") };
390
+ } finally {
391
+ controller.abort();
392
+ release?.();
393
+ this.#active.delete(turn.channel);
394
+ }
395
+ }
396
+ }
package/src/protocol.ts CHANGED
@@ -79,14 +79,21 @@ export function validCallId(id: unknown): id is string {
79
79
  export async function boundedText(
80
80
  stream: ReadableStream<Uint8Array> | null,
81
81
  limit: number,
82
+ signal?: AbortSignal,
82
83
  ): Promise<string> {
84
+ signal?.throwIfAborted();
83
85
  if (!stream) return "";
84
86
  const reader = stream.getReader();
87
+ const abort = () => {
88
+ void reader.cancel().catch(() => {});
89
+ };
90
+ signal?.addEventListener("abort", abort, { once: true });
85
91
  const chunks: Uint8Array[] = [];
86
92
  let size = 0;
87
93
  try {
88
94
  for (;;) {
89
95
  const { done, value } = await reader.read();
96
+ signal?.throwIfAborted();
90
97
  if (done) break;
91
98
  size += value.byteLength;
92
99
  if (size > limit) {
@@ -96,6 +103,7 @@ export async function boundedText(
96
103
  chunks.push(value);
97
104
  }
98
105
  } finally {
106
+ signal?.removeEventListener("abort", abort);
99
107
  reader.releaseLock();
100
108
  }
101
109
  const data = new Uint8Array(size);
@@ -0,0 +1,132 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ activeToolsExtension,
4
+ runWorkerTask,
5
+ toolText,
6
+ } from "pi-roundtable/kit";
7
+ import { type SafeFetchResult, safeFetch } from "./safe-fetch.ts";
8
+
9
+ export interface SandboxResearchOptions {
10
+ modelRuntime: ModelRuntime;
11
+ agentDir: string;
12
+ workDir: string;
13
+ model: string;
14
+ thinking: "low" | "medium" | "high" | "xhigh";
15
+ search(query: string, signal: AbortSignal): Promise<string>;
16
+ /** Parse only these already-bounded fetched bytes; never re-fetch the URL. */
17
+ extractFetched?(
18
+ result: SafeFetchResult,
19
+ signal: AbortSignal,
20
+ ): Promise<string>;
21
+ /**
22
+ * A host-owned fetch-and-extract for a model-supplied URL, replacing the built-in bounded fetch
23
+ * plus `extractFetched`. The host is then responsible for refusing unsafe and private addresses
24
+ * (see `assertPublicUrl` and `safeFetch`) and for bounding time and size.
25
+ */
26
+ fetchContent?(url: string, signal: AbortSignal): Promise<string>;
27
+ }
28
+ /** Host subscription research with no built-in tools, host memory, discovery, or shell. */
29
+ export class SandboxResearchWorker {
30
+ constructor(readonly options: SandboxResearchOptions) {
31
+ if (!options.fetchContent && !options.extractFetched)
32
+ throw new Error("Research needs fetchContent or extractFetched");
33
+ }
34
+ async run(task: string, signal: AbortSignal): Promise<string> {
35
+ signal.throwIfAborted();
36
+ const {
37
+ createAgentSession,
38
+ DefaultResourceLoader,
39
+ SessionManager,
40
+ SettingsManager,
41
+ } = await import("@earendil-works/pi-coding-agent");
42
+ const { Type } = await import("typebox");
43
+ const options = this.options;
44
+ const loader = new DefaultResourceLoader({
45
+ cwd: options.workDir,
46
+ agentDir: options.agentDir,
47
+ // Nothing from the host's settings or a global SYSTEM.md may reach a guest-triggered session.
48
+ settingsManager: SettingsManager.inMemory({}),
49
+ systemPromptOverride: () => undefined,
50
+ noExtensions: true,
51
+ noSkills: true,
52
+ noPromptTemplates: true,
53
+ noThemes: true,
54
+ noContextFiles: true,
55
+ extensionFactories: [
56
+ {
57
+ name: "research-tools",
58
+ factory: (pi) => {
59
+ pi.registerTool({
60
+ name: "web_search",
61
+ label: "Search",
62
+ description: "Search public sources and return a sourced answer.",
63
+ parameters: Type.Object({
64
+ query: Type.String({ minLength: 1, maxLength: 4000 }),
65
+ }),
66
+ execute: async (_id, input, callSignal) =>
67
+ toolText(
68
+ await options.search(
69
+ input.query,
70
+ AbortSignal.any([
71
+ signal,
72
+ ...(callSignal ? [callSignal] : []),
73
+ ]),
74
+ ),
75
+ ),
76
+ });
77
+ pi.registerTool({
78
+ name: "fetch_content",
79
+ label: "Fetch page",
80
+ description:
81
+ "Read a public HTTP(S) page. Private addresses are refused.",
82
+ parameters: Type.Object({
83
+ url: Type.String({ maxLength: 8000 }),
84
+ }),
85
+ execute: async (_id, input, callSignal) => {
86
+ const bound = AbortSignal.any([
87
+ signal,
88
+ ...(callSignal ? [callSignal] : []),
89
+ ]);
90
+ if (options.fetchContent)
91
+ return toolText(await options.fetchContent(input.url, bound));
92
+ return toolText(
93
+ await (
94
+ options.extractFetched as NonNullable<
95
+ typeof options.extractFetched
96
+ >
97
+ )(await safeFetch(input.url, { signal: bound }), bound),
98
+ );
99
+ },
100
+ });
101
+ },
102
+ },
103
+ {
104
+ name: "research-only",
105
+ factory: activeToolsExtension(() => ["web_search", "fetch_content"]),
106
+ },
107
+ ],
108
+ appendSystemPrompt: [
109
+ "You are a research worker. Research the task using public sources. Report in the task's language, self-contained, with source links; state what could not be verified. You have no host shell, owner data, memory, or agent tools.",
110
+ ],
111
+ });
112
+ await loader.reload();
113
+ signal.throwIfAborted();
114
+ const { session } = await createAgentSession({
115
+ cwd: options.workDir,
116
+ agentDir: options.agentDir,
117
+ modelRuntime: options.modelRuntime,
118
+ resourceLoader: loader,
119
+ thinkingLevel: options.thinking,
120
+ sessionManager: SessionManager.inMemory(options.workDir),
121
+ settingsManager: SettingsManager.inMemory({}),
122
+ noTools: "builtin",
123
+ });
124
+ return runWorkerTask(session, {
125
+ modelRuntime: options.modelRuntime,
126
+ model: options.model,
127
+ task,
128
+ signal,
129
+ aborted: "Research deadline exceeded",
130
+ });
131
+ }
132
+ }