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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/examples/roundtable.config.ts +36 -0
- package/package.json +47 -0
- package/src/broker.ts +387 -0
- package/src/channel-store.ts +81 -0
- package/src/claim.ts +77 -0
- package/src/commands.ts +63 -0
- package/src/container-driver.ts +193 -0
- package/src/index.ts +29 -0
- package/src/model-input.ts +177 -0
- package/src/plugin.ts +85 -0
- package/src/protocol.ts +108 -0
- package/src/runtime.ts +240 -0
- package/worker/Dockerfile +6 -0
- package/worker/agent.ts +271 -0
- package/worker/main.ts +16 -0
- package/worker/memory.ts +138 -0
- package/worker/transport.ts +61 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
boundedText,
|
|
5
|
+
isRecord,
|
|
6
|
+
type SandboxReply,
|
|
7
|
+
type SandboxTurn,
|
|
8
|
+
} from "./protocol.ts";
|
|
9
|
+
|
|
10
|
+
export interface ContainerSpec {
|
|
11
|
+
name: string;
|
|
12
|
+
image: string;
|
|
13
|
+
runDir: string;
|
|
14
|
+
workspaceDir: string;
|
|
15
|
+
uid: number;
|
|
16
|
+
gid: number;
|
|
17
|
+
memoryMb?: number;
|
|
18
|
+
cpus?: number;
|
|
19
|
+
pids?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ContainerDriver {
|
|
22
|
+
run(
|
|
23
|
+
spec: ContainerSpec,
|
|
24
|
+
turn: SandboxTurn,
|
|
25
|
+
signal: AbortSignal,
|
|
26
|
+
): Promise<SandboxReply>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pure argument generation: no Docker daemon is required to test the isolation boundary. */
|
|
30
|
+
export function containerRunArgs(spec: ContainerSpec): string[] {
|
|
31
|
+
if (
|
|
32
|
+
!/^[a-z0-9][a-z0-9_.-]{0,90}$/.test(spec.name) ||
|
|
33
|
+
!spec.image ||
|
|
34
|
+
spec.image.startsWith("-") ||
|
|
35
|
+
/\s/.test(spec.image)
|
|
36
|
+
)
|
|
37
|
+
throw new Error("invalid container name or image");
|
|
38
|
+
if (
|
|
39
|
+
!Number.isInteger(spec.uid) ||
|
|
40
|
+
spec.uid <= 0 ||
|
|
41
|
+
!Number.isInteger(spec.gid) ||
|
|
42
|
+
spec.gid <= 0
|
|
43
|
+
)
|
|
44
|
+
throw new Error("sandbox requires a non-root user and group");
|
|
45
|
+
for (const dir of [spec.runDir, spec.workspaceDir]) {
|
|
46
|
+
if (!isAbsolute(dir) || dir === "/" || /[,\n\r]/.test(dir))
|
|
47
|
+
throw new Error("mount paths must be absolute dedicated directories");
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
spec.runDir === spec.workspaceDir ||
|
|
51
|
+
spec.runDir.startsWith(`${spec.workspaceDir}/`) ||
|
|
52
|
+
spec.workspaceDir.startsWith(`${spec.runDir}/`)
|
|
53
|
+
)
|
|
54
|
+
throw new Error("broker and workspace mounts must be separate");
|
|
55
|
+
const memory = spec.memoryMb ?? 512;
|
|
56
|
+
const cpus = spec.cpus ?? 1;
|
|
57
|
+
const pids = spec.pids ?? 64;
|
|
58
|
+
if (
|
|
59
|
+
!Number.isSafeInteger(memory) ||
|
|
60
|
+
memory < 64 ||
|
|
61
|
+
!Number.isFinite(cpus) ||
|
|
62
|
+
cpus <= 0 ||
|
|
63
|
+
!Number.isSafeInteger(pids) ||
|
|
64
|
+
pids < 8
|
|
65
|
+
)
|
|
66
|
+
throw new Error("invalid resource limits");
|
|
67
|
+
return [
|
|
68
|
+
"run",
|
|
69
|
+
"--rm",
|
|
70
|
+
"--interactive",
|
|
71
|
+
"--sig-proxy=false",
|
|
72
|
+
"--log-driver",
|
|
73
|
+
"none",
|
|
74
|
+
"--pull",
|
|
75
|
+
"never",
|
|
76
|
+
"--name",
|
|
77
|
+
spec.name,
|
|
78
|
+
"--network",
|
|
79
|
+
"none",
|
|
80
|
+
"--read-only",
|
|
81
|
+
"--cap-drop",
|
|
82
|
+
"ALL",
|
|
83
|
+
"--security-opt",
|
|
84
|
+
"no-new-privileges:true",
|
|
85
|
+
"--user",
|
|
86
|
+
`${spec.uid}:${spec.gid}`,
|
|
87
|
+
"--memory",
|
|
88
|
+
`${memory}m`,
|
|
89
|
+
"--memory-swap",
|
|
90
|
+
`${memory}m`,
|
|
91
|
+
"--cpus",
|
|
92
|
+
String(cpus),
|
|
93
|
+
"--pids-limit",
|
|
94
|
+
String(pids),
|
|
95
|
+
"--ulimit",
|
|
96
|
+
"nofile=256:256",
|
|
97
|
+
"--tmpfs",
|
|
98
|
+
"/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777",
|
|
99
|
+
"--mount",
|
|
100
|
+
`type=bind,src=${spec.runDir},dst=/broker,readonly,bind-propagation=rprivate`,
|
|
101
|
+
"--mount",
|
|
102
|
+
`type=bind,src=${spec.workspaceDir},dst=/workspace,bind-propagation=rprivate`,
|
|
103
|
+
"--env",
|
|
104
|
+
"HOME=/tmp/home",
|
|
105
|
+
spec.image,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class DockerContainerDriver implements ContainerDriver {
|
|
110
|
+
readonly #binary: string;
|
|
111
|
+
constructor(binary = "docker") {
|
|
112
|
+
this.#binary = binary;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async run(
|
|
116
|
+
spec: ContainerSpec,
|
|
117
|
+
turn: SandboxTurn,
|
|
118
|
+
signal: AbortSignal,
|
|
119
|
+
): Promise<SandboxReply> {
|
|
120
|
+
// Canonical paths prevent symlinks in operator configuration selecting a different mount.
|
|
121
|
+
const args = containerRunArgs({
|
|
122
|
+
...spec,
|
|
123
|
+
runDir: realpathSync(spec.runDir),
|
|
124
|
+
workspaceDir: realpathSync(spec.workspaceDir),
|
|
125
|
+
});
|
|
126
|
+
signal.throwIfAborted();
|
|
127
|
+
const child = Bun.spawn([this.#binary, ...args], {
|
|
128
|
+
stdin: "pipe",
|
|
129
|
+
stdout: "pipe",
|
|
130
|
+
stderr: "pipe",
|
|
131
|
+
});
|
|
132
|
+
// SIGTERM can be proxied into an uncooperative PID 1 forever; kill this CLI directly.
|
|
133
|
+
const abort = () => child.kill("SIGKILL");
|
|
134
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
135
|
+
try {
|
|
136
|
+
child.stdin.write(JSON.stringify(turn));
|
|
137
|
+
child.stdin.end();
|
|
138
|
+
const [output, , code] = await Promise.all([
|
|
139
|
+
boundedText(child.stdout, 2 * 1024 * 1024),
|
|
140
|
+
boundedText(child.stderr, 128 * 1024),
|
|
141
|
+
child.exited,
|
|
142
|
+
]);
|
|
143
|
+
signal.throwIfAborted();
|
|
144
|
+
if (code !== 0) throw new Error("sandbox worker failed");
|
|
145
|
+
const reply: unknown = JSON.parse(output);
|
|
146
|
+
if (
|
|
147
|
+
!isRecord(reply) ||
|
|
148
|
+
typeof reply.ok !== "boolean" ||
|
|
149
|
+
typeof reply.text !== "string" ||
|
|
150
|
+
reply.text.length > 100_000
|
|
151
|
+
)
|
|
152
|
+
throw new Error("invalid sandbox reply");
|
|
153
|
+
return { ok: reply.ok, text: reply.text };
|
|
154
|
+
} finally {
|
|
155
|
+
signal.removeEventListener("abort", abort);
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
await child.exited;
|
|
158
|
+
// Do not wait on a cooperative container exit; force-remove its exact name.
|
|
159
|
+
await this.#remove(spec.name);
|
|
160
|
+
if (signal.aborted || child.exitCode !== 0) {
|
|
161
|
+
// Cover the short create/attach race after killing the run client.
|
|
162
|
+
await Bun.sleep(100);
|
|
163
|
+
await this.#remove(spec.name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #remove(name: string): Promise<void> {
|
|
169
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
170
|
+
const cleanup = Bun.spawn([this.#binary, "rm", "--force", name], {
|
|
171
|
+
stdout: "ignore",
|
|
172
|
+
stderr: "pipe",
|
|
173
|
+
});
|
|
174
|
+
const timer = setTimeout(() => cleanup.kill("SIGKILL"), 5000);
|
|
175
|
+
try {
|
|
176
|
+
const [error, code] = await Promise.all([
|
|
177
|
+
boundedText(cleanup.stderr, 16 * 1024),
|
|
178
|
+
cleanup.exited,
|
|
179
|
+
]);
|
|
180
|
+
if (code === 0 || /No such (container|object)/i.test(error)) return;
|
|
181
|
+
if (attempt === 2 || !/removal.*already in progress/is.test(error))
|
|
182
|
+
throw new Error(
|
|
183
|
+
"sandbox container cleanup failed; inspect this installation's containers",
|
|
184
|
+
);
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
cleanup.kill("SIGKILL");
|
|
188
|
+
await cleanup.exited;
|
|
189
|
+
}
|
|
190
|
+
await Bun.sleep(100 * (attempt + 1));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BrokerListener,
|
|
3
|
+
type BrokerOptions,
|
|
4
|
+
type HostTool,
|
|
5
|
+
type HostToolContext,
|
|
6
|
+
type McpServer,
|
|
7
|
+
SandboxBroker,
|
|
8
|
+
} from "./broker.ts";
|
|
9
|
+
export { SandboxChannelStore } from "./channel-store.ts";
|
|
10
|
+
export {
|
|
11
|
+
isSandboxAddress,
|
|
12
|
+
type SandboxClaimOptions,
|
|
13
|
+
sandboxClaim,
|
|
14
|
+
} from "./claim.ts";
|
|
15
|
+
export { sandboxCommands } from "./commands.ts";
|
|
16
|
+
export {
|
|
17
|
+
type ContainerDriver,
|
|
18
|
+
type ContainerSpec,
|
|
19
|
+
containerRunArgs,
|
|
20
|
+
DockerContainerDriver,
|
|
21
|
+
} from "./container-driver.ts";
|
|
22
|
+
export {
|
|
23
|
+
SANDBOX,
|
|
24
|
+
type SandboxOptions,
|
|
25
|
+
type SandboxService,
|
|
26
|
+
sandbox,
|
|
27
|
+
} from "./plugin.ts";
|
|
28
|
+
export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
|
|
29
|
+
export { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validFunctionName as functionName,
|
|
3
|
+
validCallId as identifier,
|
|
4
|
+
isRecord,
|
|
5
|
+
} from "./protocol.ts";
|
|
6
|
+
|
|
7
|
+
interface ModelInput {
|
|
8
|
+
messages: Record<string, unknown>[];
|
|
9
|
+
tools?: Record<string, unknown>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function localSchema(value: unknown): boolean {
|
|
13
|
+
if (typeof value === "boolean") return true;
|
|
14
|
+
if (!isRecord(value)) return false;
|
|
15
|
+
for (const key of [
|
|
16
|
+
"$ref",
|
|
17
|
+
"$dynamicRef",
|
|
18
|
+
"$recursiveRef",
|
|
19
|
+
"$schema",
|
|
20
|
+
"$id",
|
|
21
|
+
]) {
|
|
22
|
+
if (
|
|
23
|
+
value[key] !== undefined &&
|
|
24
|
+
(typeof value[key] !== "string" || !value[key].startsWith("#"))
|
|
25
|
+
)
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
value.$vocabulary !== undefined &&
|
|
30
|
+
(!isRecord(value.$vocabulary) ||
|
|
31
|
+
Object.keys(value.$vocabulary).some((uri) => !uri.startsWith("#")))
|
|
32
|
+
)
|
|
33
|
+
return false;
|
|
34
|
+
// Recurse only into schema positions; property names and enum/const data are not keywords.
|
|
35
|
+
for (const key of [
|
|
36
|
+
"properties",
|
|
37
|
+
"patternProperties",
|
|
38
|
+
"$defs",
|
|
39
|
+
"definitions",
|
|
40
|
+
"dependentSchemas",
|
|
41
|
+
]) {
|
|
42
|
+
if (
|
|
43
|
+
value[key] !== undefined &&
|
|
44
|
+
(!isRecord(value[key]) || !Object.values(value[key]).every(localSchema))
|
|
45
|
+
)
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
for (const key of [
|
|
49
|
+
"items",
|
|
50
|
+
"contains",
|
|
51
|
+
"additionalProperties",
|
|
52
|
+
"unevaluatedProperties",
|
|
53
|
+
"unevaluatedItems",
|
|
54
|
+
"propertyNames",
|
|
55
|
+
"not",
|
|
56
|
+
"if",
|
|
57
|
+
"then",
|
|
58
|
+
"else",
|
|
59
|
+
"contentSchema",
|
|
60
|
+
"additionalItems",
|
|
61
|
+
]) {
|
|
62
|
+
if (
|
|
63
|
+
value[key] !== undefined &&
|
|
64
|
+
!(Array.isArray(value[key])
|
|
65
|
+
? value[key].every(localSchema)
|
|
66
|
+
: localSchema(value[key]))
|
|
67
|
+
)
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
|
71
|
+
if (
|
|
72
|
+
value[key] !== undefined &&
|
|
73
|
+
(!Array.isArray(value[key]) || !value[key].every(localSchema))
|
|
74
|
+
)
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
isRecord(value.dependencies) &&
|
|
79
|
+
Object.values(value.dependencies).some(
|
|
80
|
+
(entry) => !Array.isArray(entry) && !localSchema(entry),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Model traffic is text plus function calls only: no account files, media URLs or native provider tools. */
|
|
88
|
+
export function modelInput(
|
|
89
|
+
body: Record<string, unknown>,
|
|
90
|
+
): ModelInput | undefined {
|
|
91
|
+
if (!Array.isArray(body.messages) || body.messages.length > 128)
|
|
92
|
+
return undefined;
|
|
93
|
+
const messages: Record<string, unknown>[] = [];
|
|
94
|
+
for (const raw of body.messages) {
|
|
95
|
+
if (
|
|
96
|
+
!isRecord(raw) ||
|
|
97
|
+
typeof raw.role !== "string" ||
|
|
98
|
+
!["system", "user", "assistant", "tool"].includes(raw.role) ||
|
|
99
|
+
typeof raw.content !== "string"
|
|
100
|
+
)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (
|
|
103
|
+
Object.keys(raw).some(
|
|
104
|
+
(key) =>
|
|
105
|
+
!["role", "content", "tool_calls", "tool_call_id"].includes(key),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return undefined;
|
|
109
|
+
const message: Record<string, unknown> = {
|
|
110
|
+
role: raw.role,
|
|
111
|
+
content: raw.content,
|
|
112
|
+
};
|
|
113
|
+
if (raw.tool_calls !== undefined) {
|
|
114
|
+
if (
|
|
115
|
+
raw.role !== "assistant" ||
|
|
116
|
+
!Array.isArray(raw.tool_calls) ||
|
|
117
|
+
raw.tool_calls.length > 8
|
|
118
|
+
)
|
|
119
|
+
return undefined;
|
|
120
|
+
const calls: Record<string, unknown>[] = [];
|
|
121
|
+
for (const call of raw.tool_calls) {
|
|
122
|
+
if (
|
|
123
|
+
!isRecord(call) ||
|
|
124
|
+
call.type !== "function" ||
|
|
125
|
+
!identifier(call.id) ||
|
|
126
|
+
!isRecord(call.function) ||
|
|
127
|
+
!functionName(call.function.name) ||
|
|
128
|
+
typeof call.function.arguments !== "string"
|
|
129
|
+
)
|
|
130
|
+
return undefined;
|
|
131
|
+
calls.push({
|
|
132
|
+
id: call.id,
|
|
133
|
+
type: "function",
|
|
134
|
+
function: {
|
|
135
|
+
name: call.function.name,
|
|
136
|
+
arguments: call.function.arguments,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
message.tool_calls = calls;
|
|
141
|
+
}
|
|
142
|
+
if (raw.role === "tool") {
|
|
143
|
+
if (!identifier(raw.tool_call_id)) return undefined;
|
|
144
|
+
message.tool_call_id = raw.tool_call_id;
|
|
145
|
+
} else if (raw.tool_call_id !== undefined) return undefined;
|
|
146
|
+
messages.push(message);
|
|
147
|
+
}
|
|
148
|
+
let tools: Record<string, unknown>[] | undefined;
|
|
149
|
+
if (body.tools !== undefined) {
|
|
150
|
+
if (!Array.isArray(body.tools) || body.tools.length > 128) return undefined;
|
|
151
|
+
tools = [];
|
|
152
|
+
const names = new Set<string>();
|
|
153
|
+
for (const tool of body.tools) {
|
|
154
|
+
if (
|
|
155
|
+
!isRecord(tool) ||
|
|
156
|
+
tool.type !== "function" ||
|
|
157
|
+
!isRecord(tool.function) ||
|
|
158
|
+
!functionName(tool.function.name) ||
|
|
159
|
+
names.has(tool.function.name) ||
|
|
160
|
+
typeof tool.function.description !== "string" ||
|
|
161
|
+
!isRecord(tool.function.parameters) ||
|
|
162
|
+
!localSchema(tool.function.parameters)
|
|
163
|
+
)
|
|
164
|
+
return undefined;
|
|
165
|
+
names.add(tool.function.name);
|
|
166
|
+
tools.push({
|
|
167
|
+
type: "function",
|
|
168
|
+
function: {
|
|
169
|
+
name: tool.function.name,
|
|
170
|
+
description: tool.function.description,
|
|
171
|
+
parameters: tool.function.parameters,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { messages, ...(tools ? { tools } : {}) };
|
|
177
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { type ChannelKey, definePlugin, serviceKey } from "pi-roundtable";
|
|
4
|
+
import { DISCORD } from "pi-roundtable/discord";
|
|
5
|
+
import { SandboxChannelStore } from "./channel-store.ts";
|
|
6
|
+
import { sandboxClaim } from "./claim.ts";
|
|
7
|
+
import { sandboxCommands } from "./commands.ts";
|
|
8
|
+
import { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
|
|
9
|
+
|
|
10
|
+
export interface SandboxOptions
|
|
11
|
+
extends Omit<SandboxRuntimeOptions, "apiKey" | "timeZone"> {
|
|
12
|
+
/** Persistent, host-only file outside both mounted directories. */
|
|
13
|
+
stateFile: string;
|
|
14
|
+
/** Used only when stateFile does not yet exist. */
|
|
15
|
+
initialChannels?: readonly ChannelKey[];
|
|
16
|
+
/** Host login provider used for each model call, unless apiKey is supplied. */
|
|
17
|
+
provider?: string;
|
|
18
|
+
apiKey?: SandboxRuntimeOptions["apiKey"];
|
|
19
|
+
}
|
|
20
|
+
export interface SandboxService {
|
|
21
|
+
channels: SandboxChannelStore;
|
|
22
|
+
runtime: SandboxRuntime;
|
|
23
|
+
}
|
|
24
|
+
export const SANDBOX = serviceKey<SandboxService>("sandbox.channels");
|
|
25
|
+
|
|
26
|
+
export function sandbox(options: SandboxOptions) {
|
|
27
|
+
return definePlugin({
|
|
28
|
+
name: "sandbox",
|
|
29
|
+
provides: [SANDBOX],
|
|
30
|
+
setup: (context) => {
|
|
31
|
+
const runtime = new SandboxRuntime({
|
|
32
|
+
...options,
|
|
33
|
+
apiKey:
|
|
34
|
+
options.apiKey ??
|
|
35
|
+
(() => context.apiKey(options.provider ?? "openai")),
|
|
36
|
+
timeZone: context.env.timeZone,
|
|
37
|
+
});
|
|
38
|
+
if (!isAbsolute(options.stateFile))
|
|
39
|
+
throw new Error("stateFile must be absolute");
|
|
40
|
+
mkdirSync(dirname(options.stateFile), { recursive: true, mode: 0o700 });
|
|
41
|
+
const stateFile = resolve(
|
|
42
|
+
realpathSync(dirname(options.stateFile)),
|
|
43
|
+
options.stateFile.split("/").at(-1) ?? "",
|
|
44
|
+
);
|
|
45
|
+
for (const root of [
|
|
46
|
+
realpathSync(options.runRoot),
|
|
47
|
+
realpathSync(options.workspaceRoot),
|
|
48
|
+
]) {
|
|
49
|
+
if (stateFile === root || stateFile.startsWith(`${root}/`))
|
|
50
|
+
throw new Error("routing state must stay outside sandbox mounts");
|
|
51
|
+
}
|
|
52
|
+
const channels = new SandboxChannelStore(
|
|
53
|
+
stateFile,
|
|
54
|
+
options.initialChannels,
|
|
55
|
+
);
|
|
56
|
+
context.services.provide(SANDBOX, { channels, runtime });
|
|
57
|
+
const discord = context.services.find(DISCORD);
|
|
58
|
+
if (discord)
|
|
59
|
+
discord.commands.add(
|
|
60
|
+
sandboxCommands(discord.guard, channels, context.queue),
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
channels: [
|
|
64
|
+
sandboxClaim({
|
|
65
|
+
channels,
|
|
66
|
+
runtime,
|
|
67
|
+
surfaces: context.surfaces,
|
|
68
|
+
reportFailure: (channel) =>
|
|
69
|
+
context.logger.error(
|
|
70
|
+
{ channel },
|
|
71
|
+
"sandbox turn failed; inspect container cleanup and broker configuration",
|
|
72
|
+
),
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
services: [
|
|
76
|
+
{
|
|
77
|
+
name: "sandbox-runtime",
|
|
78
|
+
busy: () => runtime.busy(),
|
|
79
|
+
stop: () => runtime.dispose(),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export const DUMMY_KEY = "sandbox-dummy-key";
|
|
2
|
+
export const BROKER_PATH = "/broker/broker.sock";
|
|
3
|
+
export const WORKSPACE_PATH = "/workspace";
|
|
4
|
+
|
|
5
|
+
export interface ToolSpec {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
parameters: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SandboxTurn {
|
|
12
|
+
text: string;
|
|
13
|
+
speaker: { id: string; name: string };
|
|
14
|
+
model: string;
|
|
15
|
+
prompt: string;
|
|
16
|
+
timeZone: string;
|
|
17
|
+
tools: ToolSpec[];
|
|
18
|
+
reset?: boolean;
|
|
19
|
+
mcp: { server: string; tools: ToolSpec[] }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SandboxReply {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isToolSpec(value: unknown): value is ToolSpec {
|
|
32
|
+
return (
|
|
33
|
+
isRecord(value) &&
|
|
34
|
+
typeof value.name === "string" &&
|
|
35
|
+
validName(value.name) &&
|
|
36
|
+
typeof value.description === "string" &&
|
|
37
|
+
isRecord(value.parameters)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isSandboxTurn(value: unknown): value is SandboxTurn {
|
|
42
|
+
return (
|
|
43
|
+
isRecord(value) &&
|
|
44
|
+
typeof value.text === "string" &&
|
|
45
|
+
isRecord(value.speaker) &&
|
|
46
|
+
typeof value.speaker.id === "string" &&
|
|
47
|
+
typeof value.speaker.name === "string" &&
|
|
48
|
+
typeof value.model === "string" &&
|
|
49
|
+
typeof value.prompt === "string" &&
|
|
50
|
+
typeof value.timeZone === "string" &&
|
|
51
|
+
(value.reset === undefined || typeof value.reset === "boolean") &&
|
|
52
|
+
Array.isArray(value.tools) &&
|
|
53
|
+
value.tools.every(isToolSpec) &&
|
|
54
|
+
Array.isArray(value.mcp) &&
|
|
55
|
+
value.mcp.every(
|
|
56
|
+
(server) =>
|
|
57
|
+
isRecord(server) &&
|
|
58
|
+
typeof server.server === "string" &&
|
|
59
|
+
validName(server.server) &&
|
|
60
|
+
Array.isArray(server.tools) &&
|
|
61
|
+
server.tools.every(isToolSpec),
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validName(value: string): boolean {
|
|
67
|
+
return /^[a-z][a-z0-9_]{0,47}$/.test(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function validFunctionName(name: unknown): name is string {
|
|
71
|
+
return typeof name === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validCallId(id: unknown): id is string {
|
|
75
|
+
return typeof id === "string" && id.length > 0 && id.length <= 128;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bound untrusted input before JSON parsing, including chunked bodies. */
|
|
79
|
+
export async function boundedText(
|
|
80
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
81
|
+
limit: number,
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
if (!stream) return "";
|
|
84
|
+
const reader = stream.getReader();
|
|
85
|
+
const chunks: Uint8Array[] = [];
|
|
86
|
+
let size = 0;
|
|
87
|
+
try {
|
|
88
|
+
for (;;) {
|
|
89
|
+
const { done, value } = await reader.read();
|
|
90
|
+
if (done) break;
|
|
91
|
+
size += value.byteLength;
|
|
92
|
+
if (size > limit) {
|
|
93
|
+
await reader.cancel();
|
|
94
|
+
throw new Error("body too large");
|
|
95
|
+
}
|
|
96
|
+
chunks.push(value);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
reader.releaseLock();
|
|
100
|
+
}
|
|
101
|
+
const data = new Uint8Array(size);
|
|
102
|
+
let offset = 0;
|
|
103
|
+
for (const chunk of chunks) {
|
|
104
|
+
data.set(chunk, offset);
|
|
105
|
+
offset += chunk.byteLength;
|
|
106
|
+
}
|
|
107
|
+
return new TextDecoder().decode(data);
|
|
108
|
+
}
|