pi-roundtable-sandbox 0.7.2 → 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,194 @@
1
+ import { PI_DUMMY_TOKEN, PI_RUN_DIR, PI_WORKSPACE } from "./pi-protocol.ts";
2
+
3
+ export interface PiContainerSpec {
4
+ name: string;
5
+ image: string;
6
+ channel: string;
7
+ profile: string;
8
+ timeZone?: string;
9
+ labelChannel?: string;
10
+ labelProfile?: string;
11
+ /** Host directories mounted as the run directory and the workspace. */
12
+ runDir: string;
13
+ workspaceDir: string;
14
+ /** The host user that owns the mounted directories. */
15
+ uid: number;
16
+ gid: number;
17
+ }
18
+
19
+ export type PiContainerState = "missing" | "running" | "stopped";
20
+
21
+ export interface PiContainerStatus {
22
+ state: PiContainerState;
23
+ image?: string;
24
+ profile?: string;
25
+ }
26
+
27
+ /** Runs one container per sandbox channel; a fake replaces it in tests. */
28
+ export interface PiContainerDriver {
29
+ /** Starts the container, recreating it when it is missing, stopped, or on another image or profile. */
30
+ ensureRunning(spec: PiContainerSpec): Promise<void>;
31
+ remove(name: string): Promise<void>;
32
+ status(name: string): Promise<PiContainerStatus>;
33
+ }
34
+
35
+ export const PI_LABEL_CHANNEL = "roundtable.sandbox.channel";
36
+ export const PI_LABEL_PROFILE = "roundtable.sandbox.profile";
37
+
38
+ /**
39
+ * The Docker Engine API body for a sealed sandbox container: no network, no capabilities,
40
+ * a read-only root, and only the channel's two directories mounted.
41
+ */
42
+ export function piContainerCreateBody(spec: PiContainerSpec): object {
43
+ if (
44
+ !/^[a-z0-9][a-z0-9_.-]{0,90}$/.test(spec.name) ||
45
+ !/^[a-zA-Z0-9_-]{1,100}$/.test(spec.profile) ||
46
+ !spec.image ||
47
+ /[\s]/.test(spec.image) ||
48
+ spec.image.startsWith("-")
49
+ )
50
+ throw new Error("Invalid container specification");
51
+ if (
52
+ !Number.isSafeInteger(spec.uid) ||
53
+ spec.uid <= 0 ||
54
+ !Number.isSafeInteger(spec.gid) ||
55
+ spec.gid <= 0
56
+ )
57
+ throw new Error("Worker must be non-root");
58
+ for (const dir of [spec.runDir, spec.workspaceDir])
59
+ if (!dir.startsWith("/") || dir === "/" || /[:,\r\n]/.test(dir))
60
+ throw new Error("Invalid mount path");
61
+ new Intl.DateTimeFormat("en-US", { timeZone: spec.timeZone ?? "UTC" });
62
+ return {
63
+ Image: spec.image,
64
+ User: `${spec.uid}:${spec.gid}`,
65
+ Env: [
66
+ `SANDBOX_PROFILE=${spec.profile}`,
67
+ // Claude Code tells the model today's date from local time; guests live in Taipei.
68
+ `TZ=${spec.timeZone ?? "UTC"}`,
69
+ "ANTHROPIC_BASE_URL=http://127.0.0.1:8080/anthropic",
70
+ // Claude Code needs a token to start; the broker replaces it on the way out.
71
+ `CLAUDE_CODE_OAUTH_TOKEN=${PI_DUMMY_TOKEN}`,
72
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1",
73
+ "SANDBOX_CONTENT=/app/worker/content.ts",
74
+ "HOME=/tmp/home",
75
+ "PI_CODING_AGENT_DIR=/tmp/pi-agent",
76
+ `CLAUDE_CONFIG_DIR=${PI_WORKSPACE}/claude`,
77
+ ],
78
+ Labels: {
79
+ [spec.labelChannel ?? PI_LABEL_CHANNEL]: spec.channel,
80
+ [spec.labelProfile ?? PI_LABEL_PROFILE]: spec.profile,
81
+ },
82
+ HostConfig: {
83
+ NetworkMode: "none",
84
+ ReadonlyRootfs: true,
85
+ Tmpfs: { "/tmp": "rw,nosuid,nodev,size=512m,mode=1777" },
86
+ MemorySwap: 1536 * 1024 * 1024,
87
+ Ulimits: [{ Name: "nofile", Soft: 256, Hard: 256 }],
88
+ CapDrop: ["ALL"],
89
+ SecurityOpt: ["no-new-privileges"],
90
+ Memory: 1536 * 1024 * 1024,
91
+ NanoCpus: 1_000_000_000,
92
+ PidsLimit: 256,
93
+ Binds: [
94
+ `${spec.runDir}:${PI_RUN_DIR}:ro`,
95
+ `${spec.workspaceDir}:${PI_WORKSPACE}`,
96
+ ],
97
+ RestartPolicy: { Name: "unless-stopped" },
98
+ LogConfig: {
99
+ Type: "local",
100
+ Config: { "max-size": "10m", "max-file": "2" },
101
+ },
102
+ },
103
+ };
104
+ }
105
+
106
+ interface InspectBody {
107
+ Config?: { Image?: string; Labels?: Record<string, string> };
108
+ State?: { Running?: boolean };
109
+ }
110
+
111
+ /** PiContainerDriver over the Docker Engine API on its unix socket. */
112
+ export class PiDockerContainerDriver implements PiContainerDriver {
113
+ readonly #socket: string;
114
+
115
+ constructor(
116
+ socket = "/var/run/docker.sock",
117
+ readonly labelProfile = PI_LABEL_PROFILE,
118
+ ) {
119
+ this.#socket = socket;
120
+ }
121
+
122
+ async ensureRunning(spec: PiContainerSpec): Promise<void> {
123
+ const current = await this.status(spec.name);
124
+ if (
125
+ current.state === "running" &&
126
+ current.image === spec.image &&
127
+ current.profile === spec.profile
128
+ ) {
129
+ return;
130
+ }
131
+ if (current.state !== "missing") await this.remove(spec.name);
132
+ await this.#call(
133
+ "POST",
134
+ `/containers/create?name=${encodeURIComponent(spec.name)}`,
135
+ piContainerCreateBody(spec),
136
+ );
137
+ await this.#call(
138
+ "POST",
139
+ `/containers/${encodeURIComponent(spec.name)}/start`,
140
+ );
141
+ }
142
+
143
+ async remove(name: string): Promise<void> {
144
+ await this.#call(
145
+ "DELETE",
146
+ `/containers/${encodeURIComponent(name)}?force=true`,
147
+ undefined,
148
+ [404],
149
+ );
150
+ }
151
+
152
+ async status(name: string): Promise<PiContainerStatus> {
153
+ const response = await this.#call(
154
+ "GET",
155
+ `/containers/${encodeURIComponent(name)}/json`,
156
+ undefined,
157
+ [404],
158
+ );
159
+ if (response.status === 404) return { state: "missing" };
160
+ const body = (await response.json()) as InspectBody;
161
+ return {
162
+ state: body.State?.Running ? "running" : "stopped",
163
+ ...(body.Config?.Image ? { image: body.Config.Image } : {}),
164
+ ...(body.Config?.Labels?.[this.labelProfile]
165
+ ? { profile: body.Config.Labels[this.labelProfile] }
166
+ : {}),
167
+ };
168
+ }
169
+
170
+ async #call(
171
+ method: string,
172
+ path: string,
173
+ body?: object,
174
+ allowed: readonly number[] = [],
175
+ ): Promise<Response> {
176
+ const response = await fetch(`http://docker${path}`, {
177
+ unix: this.#socket,
178
+ signal: AbortSignal.timeout(15_000),
179
+ method,
180
+ ...(body
181
+ ? {
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify(body),
184
+ }
185
+ : {}),
186
+ });
187
+ if (!response.ok && !allowed.includes(response.status)) {
188
+ throw new Error(
189
+ `docker ${method} ${path} failed with ${response.status}: ${(await response.text()).slice(0, 300)}`,
190
+ );
191
+ }
192
+ return response;
193
+ }
194
+ }
@@ -0,0 +1,219 @@
1
+ import { localSchema } from "./model-input.ts";
2
+ import { type PiThinkingLevel, validateImages } from "./pi-protocol.ts";
3
+ import { isRecord, validCallId } from "./protocol.ts";
4
+
5
+ function validFunctionName(value: unknown): value is string {
6
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
7
+ }
8
+
9
+ function cache(value: Record<string, unknown>): Record<string, unknown> {
10
+ return isRecord(value.cache_control) &&
11
+ value.cache_control.type === "ephemeral"
12
+ ? {
13
+ cache_control: {
14
+ type: "ephemeral",
15
+ ...(value.cache_control.ttl === "1h" ? { ttl: "1h" } : {}),
16
+ },
17
+ }
18
+ : {};
19
+ }
20
+ function blocks(raw: unknown, nested = false): unknown[] | string {
21
+ if (typeof raw === "string") return raw;
22
+ if (!Array.isArray(raw) || raw.length > 256)
23
+ throw new Error("Invalid content");
24
+ return raw.map((value: unknown) => {
25
+ if (!isRecord(value)) throw new Error("Invalid block");
26
+ switch (value.type) {
27
+ case "text":
28
+ if (typeof value.text !== "string") break;
29
+ return { type: "text", text: value.text, ...cache(value) };
30
+ case "image": {
31
+ const source = value.source;
32
+ if (!isRecord(source) || source.type !== "base64") break;
33
+ validateImages([{ data: source.data, mimeType: source.media_type }]);
34
+ return {
35
+ type: "image",
36
+ source: {
37
+ type: "base64",
38
+ data: source.data,
39
+ media_type: source.media_type,
40
+ },
41
+ ...cache(value),
42
+ };
43
+ }
44
+ case "tool_use":
45
+ if (
46
+ nested ||
47
+ !validCallId(value.id) ||
48
+ !validFunctionName(value.name) ||
49
+ !isRecord(value.input)
50
+ )
51
+ break;
52
+ return {
53
+ type: "tool_use",
54
+ id: value.id,
55
+ name: value.name,
56
+ input: value.input,
57
+ ...cache(value),
58
+ };
59
+ case "tool_result":
60
+ if (nested || !validCallId(value.tool_use_id)) break;
61
+ return {
62
+ type: "tool_result",
63
+ tool_use_id: value.tool_use_id,
64
+ content: blocks(value.content, true),
65
+ ...(value.is_error === true ? { is_error: true } : {}),
66
+ ...cache(value),
67
+ };
68
+ case "thinking":
69
+ if (
70
+ nested ||
71
+ typeof value.thinking !== "string" ||
72
+ typeof value.signature !== "string"
73
+ )
74
+ break;
75
+ return {
76
+ type: "thinking",
77
+ thinking: value.thinking,
78
+ signature: value.signature,
79
+ };
80
+ case "redacted_thinking":
81
+ if (nested || typeof value.data !== "string") break;
82
+ return { type: "redacted_thinking", data: value.data };
83
+ }
84
+ throw new Error("Native tools or remote media refused");
85
+ });
86
+ }
87
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
88
+ /** Highest effort and extended-thinking budget the host-judged level allows. */
89
+ const CEILINGS: Record<PiThinkingLevel, { effort: number; budget: number }> = {
90
+ low: { effort: 0, budget: 2048 },
91
+ medium: { effort: 1, budget: 8192 },
92
+ high: { effort: 2, budget: 16384 },
93
+ xhigh: { effort: 4, budget: 32768 },
94
+ };
95
+
96
+ /** Reconstruct ordinary Anthropic text/base64-image/custom-tool traffic; never forward opaque guest objects. */
97
+ export function piModelInput(
98
+ input: Record<string, unknown>,
99
+ model: string,
100
+ countTokens: boolean,
101
+ maxOutputTokens = 128_000,
102
+ /** The host-judged level for the admitted turn; the guest can only go lower. */
103
+ thinking: PiThinkingLevel = "low",
104
+ ): Record<string, unknown> {
105
+ if (!Array.isArray(input.messages) || input.messages.length > 2000)
106
+ throw new Error("Invalid messages");
107
+ const body: Record<string, unknown> = {
108
+ model,
109
+ messages: input.messages.map((message: unknown) => {
110
+ if (
111
+ !isRecord(message) ||
112
+ !["user", "assistant"].includes(String(message.role))
113
+ )
114
+ throw new Error("Invalid role");
115
+ return { role: message.role, content: blocks(message.content) };
116
+ }),
117
+ };
118
+ if (input.system !== undefined) body.system = blocks(input.system, true);
119
+ if (input.tools !== undefined) {
120
+ if (!Array.isArray(input.tools) || input.tools.length > 128)
121
+ throw new Error("Invalid tools");
122
+ body.tools = input.tools.map((tool: unknown) => {
123
+ if (
124
+ !isRecord(tool) ||
125
+ (tool.type !== undefined && tool.type !== "custom") ||
126
+ !validFunctionName(tool.name) ||
127
+ !isRecord(tool.input_schema) ||
128
+ !localSchema(tool.input_schema) ||
129
+ (tool.description !== undefined && typeof tool.description !== "string")
130
+ )
131
+ throw new Error("Remote schema or native tool refused");
132
+ return {
133
+ name: tool.name,
134
+ input_schema: tool.input_schema,
135
+ ...(tool.description ? { description: tool.description } : {}),
136
+ ...cache(tool),
137
+ };
138
+ });
139
+ }
140
+ if (input.tool_choice !== undefined) {
141
+ const choice = input.tool_choice;
142
+ if (
143
+ !isRecord(choice) ||
144
+ !["auto", "any", "tool", "none"].includes(String(choice.type)) ||
145
+ (choice.type === "tool" && !validFunctionName(choice.name))
146
+ )
147
+ throw new Error("Invalid tool choice");
148
+ body.tool_choice = {
149
+ type: choice.type,
150
+ ...(choice.type === "tool" ? { name: choice.name } : {}),
151
+ ...(choice.disable_parallel_tool_use === true
152
+ ? { disable_parallel_tool_use: true }
153
+ : {}),
154
+ };
155
+ }
156
+ const ceiling = CEILINGS[thinking];
157
+ if (isRecord(input.thinking)) {
158
+ if (
159
+ input.thinking.type === "adaptive" ||
160
+ input.thinking.type === "disabled"
161
+ )
162
+ body.thinking = { type: input.thinking.type };
163
+ else if (
164
+ input.thinking.type === "enabled" &&
165
+ Number.isSafeInteger(input.thinking.budget_tokens) &&
166
+ Number(input.thinking.budget_tokens) >= 1024
167
+ )
168
+ body.thinking = {
169
+ type: "enabled",
170
+ budget_tokens: Math.min(
171
+ Number(input.thinking.budget_tokens),
172
+ ceiling.budget,
173
+ maxOutputTokens - 1,
174
+ ),
175
+ };
176
+ else throw new Error("Invalid thinking");
177
+ }
178
+ const asked = isRecord(input.output_config)
179
+ ? EFFORTS.indexOf(input.output_config.effort as (typeof EFFORTS)[number])
180
+ : -1;
181
+ // Adaptive thinking without an explicit effort runs at the model's default, so the host caps it too.
182
+ const effort =
183
+ asked >= 0
184
+ ? Math.min(asked, ceiling.effort)
185
+ : isRecord(body.thinking) &&
186
+ body.thinking.type === "adaptive" &&
187
+ ceiling.effort < 2
188
+ ? ceiling.effort
189
+ : -1;
190
+ if (effort >= 0) body.output_config = { effort: EFFORTS[effort] };
191
+ if (!countTokens) {
192
+ const budget =
193
+ isRecord(body.thinking) && typeof body.thinking.budget_tokens === "number"
194
+ ? body.thinking.budget_tokens
195
+ : 0;
196
+ body.max_tokens = Math.max(
197
+ budget + 1,
198
+ Math.min(
199
+ Number.isSafeInteger(input.max_tokens) && Number(input.max_tokens) > 0
200
+ ? Number(input.max_tokens)
201
+ : 16384,
202
+ maxOutputTokens,
203
+ ),
204
+ );
205
+ body.stream = input.stream === true;
206
+ for (const key of ["temperature", "top_p", "top_k"])
207
+ if (typeof input[key] === "number" && Number.isFinite(input[key]))
208
+ body[key] = input[key];
209
+ if (
210
+ Array.isArray(input.stop_sequences) &&
211
+ input.stop_sequences.length <= 16 &&
212
+ input.stop_sequences.every(
213
+ (s) => typeof s === "string" && s.length <= 1000,
214
+ )
215
+ )
216
+ body.stop_sequences = input.stop_sequences;
217
+ }
218
+ return body;
219
+ }
@@ -0,0 +1,141 @@
1
+ import { isRecord } from "./protocol.ts";
2
+
3
+ export const PI_RUN_DIR = "/run/sandbox";
4
+ export const PI_WORKSPACE = "/workspace";
5
+ export const PI_BROKER_SOCKET = "broker.sock";
6
+ export const PI_ATTACHMENTS = "attachments";
7
+ export const PI_OUTBOX = "outbox";
8
+ export const PI_FORWARDER_PORT = 8080;
9
+ export const PI_DUMMY_TOKEN = "sandbox-dummy-token";
10
+ export const PI_MEDIA_LIMITS = {
11
+ images: 8,
12
+ imageBytes: 20 * 1024 * 1024,
13
+ totalImageBytes: 64 * 1024 * 1024,
14
+ files: 10,
15
+ fileBytes: 10 * 1024 * 1024,
16
+ totalFileBytes: 50 * 1024 * 1024,
17
+ } as const;
18
+ export const PI_THINKING_LEVELS = ["low", "medium", "high", "xhigh"] as const;
19
+ export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number];
20
+ export interface PiImage {
21
+ data: string;
22
+ mimeType: string;
23
+ }
24
+ export interface PiTurnRequest {
25
+ turnId: string;
26
+ author: { id: string; name: string };
27
+ text: string;
28
+ memory: string;
29
+ images: PiImage[];
30
+ thinking: PiThinkingLevel;
31
+ }
32
+ export interface PiReplyFile {
33
+ name: string;
34
+ data: string;
35
+ }
36
+ export type PiTurnResponse =
37
+ | { ok: true; text: string; files: PiReplyFile[] }
38
+ | { ok: false; error: string };
39
+ export function validateReplyFiles(
40
+ files: unknown,
41
+ ): asserts files is PiReplyFile[] {
42
+ if (!Array.isArray(files) || files.length > PI_MEDIA_LIMITS.files)
43
+ throw new Error("Too many reply files");
44
+ let total = 0;
45
+ for (const file of files) {
46
+ if (
47
+ !isRecord(file) ||
48
+ typeof file.name !== "string" ||
49
+ !safeFileName(file.name) ||
50
+ typeof file.data !== "string" ||
51
+ file.data.length === 0 ||
52
+ file.data.length > Math.ceil(PI_MEDIA_LIMITS.fileBytes / 3) * 4 ||
53
+ file.data.length % 4 !== 0 ||
54
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(file.data)
55
+ )
56
+ throw new Error("Invalid reply file");
57
+ const size = Buffer.byteLength(file.data, "base64");
58
+ total += size;
59
+ if (
60
+ size > PI_MEDIA_LIMITS.fileBytes ||
61
+ total > PI_MEDIA_LIMITS.totalFileBytes
62
+ )
63
+ throw new Error("Reply file budget exceeded");
64
+ }
65
+ }
66
+ export interface PiToolResponse {
67
+ ok: boolean;
68
+ text?: string;
69
+ error?: string;
70
+ image?: PiImage;
71
+ }
72
+ export interface PiTurnContext {
73
+ authorId: string;
74
+ authorName: string;
75
+ outbox: string;
76
+ memory: string;
77
+ }
78
+ export interface PiMcpDiscovery {
79
+ servers: { name: string; tools: string[] }[];
80
+ }
81
+ export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
82
+ return (
83
+ typeof value === "string" &&
84
+ (PI_THINKING_LEVELS as readonly string[]).includes(value)
85
+ );
86
+ }
87
+ export function safeFileName(name: string): boolean {
88
+ return (
89
+ name.length > 0 &&
90
+ name.length <= 255 &&
91
+ !/[\\/\\\\\p{Cc}]/u.test(name) &&
92
+ name !== "." &&
93
+ name !== ".."
94
+ );
95
+ }
96
+ export function validateImages(images: unknown): asserts images is PiImage[] {
97
+ if (!Array.isArray(images) || images.length > PI_MEDIA_LIMITS.images)
98
+ throw new Error("Too many images");
99
+ let total = 0;
100
+ for (const image of images) {
101
+ if (
102
+ !isRecord(image) ||
103
+ typeof image.data !== "string" ||
104
+ typeof image.mimeType !== "string" ||
105
+ !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(
106
+ image.mimeType,
107
+ ) ||
108
+ image.data.length > Math.ceil(PI_MEDIA_LIMITS.imageBytes / 3) * 4 ||
109
+ image.data.length % 4 !== 0 ||
110
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(image.data)
111
+ )
112
+ throw new Error("Invalid or oversized image");
113
+ const size = Buffer.byteLength(image.data, "base64");
114
+ total += size;
115
+ if (
116
+ size === 0 ||
117
+ size > PI_MEDIA_LIMITS.imageBytes ||
118
+ total > PI_MEDIA_LIMITS.totalImageBytes
119
+ )
120
+ throw new Error("Image budget exceeded");
121
+ }
122
+ }
123
+ export function validatePiTurn(value: unknown): asserts value is PiTurnRequest {
124
+ if (
125
+ !isRecord(value) ||
126
+ typeof value.turnId !== "string" ||
127
+ !safeFileName(value.turnId) ||
128
+ !isRecord(value.author) ||
129
+ typeof value.author.id !== "string" ||
130
+ typeof value.author.name !== "string" ||
131
+ value.author.id.length > 256 ||
132
+ value.author.name.length > 256 ||
133
+ typeof value.text !== "string" ||
134
+ value.text.length > 100_000 ||
135
+ typeof value.memory !== "string" ||
136
+ value.memory.length > 100_000 ||
137
+ !isPiThinkingLevel(value.thinking)
138
+ )
139
+ throw new Error("Invalid turn");
140
+ validateImages(value.images);
141
+ }