@stixxert/pi-docker-sandbox 0.1.0 → 1.1.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/README.md +82 -2
- package/boundary.md +13 -2
- package/index.ts +268 -44
- package/package.json +23 -5
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +467 -0
- package/sandbox/index.ts +229 -0
- package/sandbox/operations.ts +496 -0
- package/sandbox/package.json +11 -0
- package/sandbox/transport.ts +377 -0
- package/sandbox/try.sh +157 -0
- package/security.md +54 -2
- package/template/Dockerfile +34 -0
- package/template/README.md +92 -0
- package/template/build.sh +168 -0
- package/template/install.sh +77 -0
- package/test-loader.mjs +53 -0
package/index.ts
CHANGED
|
@@ -228,15 +228,81 @@ function splitCommand(cmd: string): string[] {
|
|
|
228
228
|
return out;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Real path of the workspace root (resolved once; follows symlinks in the path). */
|
|
232
|
+
let realHostRoot: string | undefined;
|
|
233
|
+
function realHostRootPath(): string {
|
|
234
|
+
if (realHostRoot === undefined) {
|
|
235
|
+
try {
|
|
236
|
+
realHostRoot = fs.realpathSync(hostRoot);
|
|
237
|
+
} catch {
|
|
238
|
+
realHostRoot = hostRoot;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return realHostRoot;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* True if `target` resolves (through symlinks) to a path inside the workspace
|
|
246
|
+
* root. Handles non-existent targets by resolving the deepest existing
|
|
247
|
+
* ancestor and re-appending the missing suffix.
|
|
248
|
+
*/
|
|
249
|
+
function realpathWithin(target: string): boolean {
|
|
250
|
+
const root = realHostRootPath();
|
|
251
|
+
let probe = target;
|
|
252
|
+
const suffix: string[] = [];
|
|
253
|
+
while (!fs.existsSync(probe)) {
|
|
254
|
+
const parent = path.dirname(probe);
|
|
255
|
+
if (parent === probe) return false; // reached filesystem root
|
|
256
|
+
suffix.unshift(path.basename(probe));
|
|
257
|
+
probe = parent;
|
|
258
|
+
}
|
|
259
|
+
let realProbe: string;
|
|
260
|
+
try {
|
|
261
|
+
realProbe = fs.realpathSync(probe);
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const realTarget = suffix.length ? path.join(realProbe, ...suffix) : realProbe;
|
|
266
|
+
const rel = path.relative(root, realTarget);
|
|
267
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Reject values that could be interpreted as docker CLI flags or inject output. */
|
|
271
|
+
function assertSafeArg(value: string, what: string): void {
|
|
272
|
+
if (!value || value.startsWith("-")) {
|
|
273
|
+
throw new Error(`docker: invalid ${what} "${value}" (must not be empty or start with "-")`);
|
|
274
|
+
}
|
|
275
|
+
if (/[\r\n\x00]/.test(value)) {
|
|
276
|
+
throw new Error(`docker: invalid ${what} (must not contain newlines or NUL)`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
231
280
|
function mapHostPath(input: string): string {
|
|
232
281
|
const trimmed = (input ?? "").trim();
|
|
233
282
|
if (!trimmed) throw new Error("docker: empty path");
|
|
283
|
+
let resolved: string;
|
|
234
284
|
if (trimmed.startsWith("/workspace")) {
|
|
235
285
|
const rel = trimmed.slice("/workspace".length).replace(/^\/+/, "");
|
|
236
|
-
|
|
286
|
+
resolved = rel ? path.join(hostRoot, rel) : hostRoot;
|
|
287
|
+
} else if (path.isAbsolute(trimmed)) {
|
|
288
|
+
resolved = trimmed;
|
|
289
|
+
} else {
|
|
290
|
+
resolved = path.resolve(hostRoot, trimmed);
|
|
291
|
+
}
|
|
292
|
+
// Confine to the workspace: reject any path that escapes hostRoot. This is
|
|
293
|
+
// the core isolation guarantee — the agent must not be able to reach host
|
|
294
|
+
// paths outside the mounted workspace (via /workspace/.. traversal or an
|
|
295
|
+
// absolute host path), because these paths are used in host-side fs calls
|
|
296
|
+
// (existence checks, docker_init writes) as well as sandbox-side mounts.
|
|
297
|
+
const rel = path.relative(hostRoot, resolved);
|
|
298
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
299
|
+
throw new Error(`docker: path escapes the workspace: ${input}`);
|
|
237
300
|
}
|
|
238
|
-
|
|
239
|
-
|
|
301
|
+
// Also reject symlinks inside the workspace that point outside it.
|
|
302
|
+
if (!realpathWithin(resolved)) {
|
|
303
|
+
throw new Error(`docker: path escapes the workspace (symlink): ${input}`);
|
|
304
|
+
}
|
|
305
|
+
return resolved;
|
|
240
306
|
}
|
|
241
307
|
|
|
242
308
|
/* ------------------------------------------------------------------ */
|
|
@@ -252,7 +318,13 @@ function runSbxCli(args: string[], timeoutMs?: number): Promise<ExecResult> {
|
|
|
252
318
|
args,
|
|
253
319
|
{ env: scrubbedEnv(), timeout: timeoutMs, maxBuffer: 128 * 1024 * 1024, windowsHide: true },
|
|
254
320
|
(err, stdout, stderr) => {
|
|
255
|
-
|
|
321
|
+
// execFile's err.code is string (e.g. "ENOENT") | number (exit code) | null (signal).
|
|
322
|
+
// Normalize to a number so callers can compare reliably.
|
|
323
|
+
let code = 0;
|
|
324
|
+
if (err) {
|
|
325
|
+
const c = (err as NodeJS.ErrnoException).code;
|
|
326
|
+
code = typeof c === "number" ? c : 1;
|
|
327
|
+
}
|
|
256
328
|
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
257
329
|
},
|
|
258
330
|
);
|
|
@@ -276,13 +348,13 @@ async function ensureSandbox(): Promise<string> {
|
|
|
276
348
|
}
|
|
277
349
|
const cpus = env.DOCKER_SANDBOX_CPUS ?? "2";
|
|
278
350
|
let mem = env.DOCKER_SANDBOX_MEMORY ?? "2g";
|
|
279
|
-
// sbx requires >= 1 GiB of memory
|
|
280
|
-
const m = /^(\d+)\s*([gGmM])?$/.exec(mem.trim());
|
|
351
|
+
// sbx requires >= 1 GiB of memory (accept decimal values like 2.5g / 512m).
|
|
352
|
+
const m = /^(\d+(?:\.\d+)?)\s*([gGmM])?$/.exec(mem.trim());
|
|
281
353
|
if (m) {
|
|
282
354
|
const v = Number(m[1]);
|
|
283
355
|
const unit = (m[2] ?? "g").toLowerCase();
|
|
284
|
-
|
|
285
|
-
if (
|
|
356
|
+
const giB = unit === "m" ? v / 1024 : v;
|
|
357
|
+
if (giB < 1) mem = "1g";
|
|
286
358
|
} else {
|
|
287
359
|
mem = "2g";
|
|
288
360
|
}
|
|
@@ -586,6 +658,7 @@ async function toolPs(all: boolean): Promise<string> {
|
|
|
586
658
|
}
|
|
587
659
|
|
|
588
660
|
async function toolPull(image: string): Promise<string> {
|
|
661
|
+
assertSafeArg(image, "image");
|
|
589
662
|
const out = await docker(["pull", image], 600_000);
|
|
590
663
|
const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
591
664
|
const interesting = lines.filter((l) => /Status:|Digest:|Downloaded newer|up to date/i.test(l));
|
|
@@ -628,16 +701,32 @@ async function toolRun(params: RunParams): Promise<string> {
|
|
|
628
701
|
const args = ["run"];
|
|
629
702
|
args.push("--label", "com.pi.sandbox=true");
|
|
630
703
|
const foreground = params.detach === false;
|
|
704
|
+
if (params.rm && !foreground) {
|
|
705
|
+
throw new Error("docker run: --rm cannot be combined with a detached run (detach defaults to true); use detach=false for a foreground run that auto-removes");
|
|
706
|
+
}
|
|
631
707
|
if (!foreground) args.push("-d");
|
|
632
708
|
if (params.rm) args.push("--rm");
|
|
633
|
-
if (params.name)
|
|
634
|
-
|
|
709
|
+
if (params.name) {
|
|
710
|
+
assertSafeArg(params.name, "container name");
|
|
711
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(params.name)) {
|
|
712
|
+
throw new Error(`docker run: invalid container name "${params.name}"`);
|
|
713
|
+
}
|
|
714
|
+
args.push("--name", params.name);
|
|
715
|
+
}
|
|
716
|
+
for (const p of params.ports ?? []) {
|
|
717
|
+
const spec = p.trim();
|
|
718
|
+
if (!/^\d+(?::\d+)?(\/(udp|tcp))?$/.test(spec)) {
|
|
719
|
+
throw new Error(`docker run: bad port spec "${p}" (use HOST:CONTAINER[/udp], e.g. "8080:3000")`);
|
|
720
|
+
}
|
|
721
|
+
args.push("-p", spec);
|
|
722
|
+
}
|
|
635
723
|
for (const e of Array.isArray(params.env) ? params.env : (params.env ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
636
|
-
if (e
|
|
724
|
+
if (e.startsWith("-")) throw new Error(`docker run: bad env entry "${e}"`);
|
|
725
|
+
args.push("-e", e);
|
|
637
726
|
}
|
|
638
727
|
for (const v of params.volumes ?? []) {
|
|
639
728
|
const parts = v.split(":");
|
|
640
|
-
if (parts.length < 2) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
|
|
729
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`docker run: bad volume spec "${v}" (use host:container[:ro])`);
|
|
641
730
|
const hostPart = mapHostPath(parts[0]);
|
|
642
731
|
const rest = parts.slice(1).join(":");
|
|
643
732
|
// In RO-workspace mode, binds sourced from the project are read-only by
|
|
@@ -650,8 +739,14 @@ async function toolRun(params: RunParams): Promise<string> {
|
|
|
650
739
|
// sandbox VM being idle-stopped by sandboxd; foreground runs stay ephemeral.
|
|
651
740
|
const restart = params.restart ?? (foreground ? "no" : "unless-stopped");
|
|
652
741
|
if (restart && restart !== "no") args.push("--restart", restart);
|
|
653
|
-
if (params.memory)
|
|
742
|
+
if (params.memory) {
|
|
743
|
+
if (!/^\d+(\.\d+)?[bkmg]?$/i.test(params.memory.trim())) {
|
|
744
|
+
throw new Error(`docker run: bad memory limit "${params.memory}" (use e.g. 512m, 1g)`);
|
|
745
|
+
}
|
|
746
|
+
args.push("-m", params.memory.trim());
|
|
747
|
+
}
|
|
654
748
|
if (params.workdir) args.push("-w", params.workdir);
|
|
749
|
+
assertSafeArg(params.image, "image");
|
|
655
750
|
args.push(params.image);
|
|
656
751
|
if (params.command) {
|
|
657
752
|
const cmd = Array.isArray(params.command) ? params.command : splitCommand(params.command);
|
|
@@ -715,6 +810,7 @@ async function toolRun(params: RunParams): Promise<string> {
|
|
|
715
810
|
}
|
|
716
811
|
|
|
717
812
|
async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<string> {
|
|
813
|
+
assertSafeArg(id, "container id");
|
|
718
814
|
const args = ["logs"];
|
|
719
815
|
if (tail > 0) args.push("--tail", String(tail));
|
|
720
816
|
if (timestamps) args.push("--timestamps");
|
|
@@ -724,6 +820,10 @@ async function toolLogs(id: string, tail: number, timestamps: boolean): Promise<
|
|
|
724
820
|
}
|
|
725
821
|
|
|
726
822
|
async function toolExec(id: string, command: string | string[]): Promise<string> {
|
|
823
|
+
if (command === undefined || command === null || (typeof command === "string" && !command.trim())) {
|
|
824
|
+
throw new Error("docker exec: empty command");
|
|
825
|
+
}
|
|
826
|
+
assertSafeArg(id, "container id");
|
|
727
827
|
const cmd = Array.isArray(command) ? command : splitCommand(command);
|
|
728
828
|
if (!cmd.length) throw new Error("docker exec: empty command");
|
|
729
829
|
const out = await docker(["exec", id, ...cmd]);
|
|
@@ -741,8 +841,16 @@ async function toolBuild(
|
|
|
741
841
|
const stat = fs.statSync(hostContext);
|
|
742
842
|
if (!stat.isDirectory()) throw new Error(`docker build: context must be a directory: ${context}`);
|
|
743
843
|
|
|
844
|
+
assertSafeArg(tag, "tag");
|
|
744
845
|
const args = ["build", "-t", tag];
|
|
745
|
-
if (dockerfile)
|
|
846
|
+
if (dockerfile) {
|
|
847
|
+
const df = path.resolve(hostContext, dockerfile);
|
|
848
|
+
const dfRel = path.relative(hostContext, df);
|
|
849
|
+
if (dfRel === ".." || dfRel.startsWith(`..${path.sep}`) || path.isAbsolute(dfRel)) {
|
|
850
|
+
throw new Error(`docker build: dockerfile path escapes the context: ${dockerfile}`);
|
|
851
|
+
}
|
|
852
|
+
args.push("-f", df);
|
|
853
|
+
}
|
|
746
854
|
if (buildArgs) {
|
|
747
855
|
try {
|
|
748
856
|
const parsed = JSON.parse(buildArgs) as Record<string, unknown>;
|
|
@@ -862,6 +970,12 @@ async function toolCompose(
|
|
|
862
970
|
}
|
|
863
971
|
|
|
864
972
|
const args = ["compose", "-f", hostFile];
|
|
973
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(action)) {
|
|
974
|
+
throw new Error(`docker compose: invalid action "${action}"`);
|
|
975
|
+
}
|
|
976
|
+
if (service && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(service)) {
|
|
977
|
+
throw new Error(`docker compose: invalid service "${service}"`);
|
|
978
|
+
}
|
|
865
979
|
switch (action) {
|
|
866
980
|
case "up":
|
|
867
981
|
args.push("up", "-d", "--build");
|
|
@@ -989,6 +1103,7 @@ async function unpublishMappingsFor(sandboxPorts: Set<string>): Promise<string[]
|
|
|
989
1103
|
}
|
|
990
1104
|
|
|
991
1105
|
async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<string> {
|
|
1106
|
+
assertSafeArg(id, "container id");
|
|
992
1107
|
switch (op) {
|
|
993
1108
|
case "stop":
|
|
994
1109
|
await docker(["stop", "--time", "10", id]);
|
|
@@ -1024,6 +1139,27 @@ async function toolLifecycle(id: string, op: "stop" | "start" | "rm"): Promise<s
|
|
|
1024
1139
|
}
|
|
1025
1140
|
}
|
|
1026
1141
|
|
|
1142
|
+
/** HTTP methods docker_curl may issue (no CONNECT/TRACE — no tunneling). */
|
|
1143
|
+
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"]);
|
|
1144
|
+
/** Max request body size for docker_curl (1 MiB). */
|
|
1145
|
+
const MAX_CURL_BODY = 1024 * 1024;
|
|
1146
|
+
|
|
1147
|
+
/** Host ports currently published by this session's sandbox (from `sbx ports`). */
|
|
1148
|
+
async function publishedHostPorts(): Promise<Set<number>> {
|
|
1149
|
+
const name = sessionSandboxName();
|
|
1150
|
+
const out = new Set<number>();
|
|
1151
|
+
try {
|
|
1152
|
+
const r = await runSbxCli(["ports", name]);
|
|
1153
|
+
for (const line of r.stdout.split("\n")) {
|
|
1154
|
+
const m = /127\.0\.0\.1\s+(\d+)\s+\d+\s+(tcp|udp)/.exec(line);
|
|
1155
|
+
if (m) out.add(Number(m[1]));
|
|
1156
|
+
}
|
|
1157
|
+
} catch {
|
|
1158
|
+
/* no published ports */
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1027
1163
|
/** Host-side HTTP request to a host-local published port (sbx forwards 127.0.0.1 only). */
|
|
1028
1164
|
async function toolCurl(url: string, timeoutSec: number, method: string, body: string | undefined): Promise<string> {
|
|
1029
1165
|
let u: URL;
|
|
@@ -1032,22 +1168,45 @@ async function toolCurl(url: string, timeoutSec: number, method: string, body: s
|
|
|
1032
1168
|
} catch {
|
|
1033
1169
|
throw new Error(`docker_curl: invalid URL "${url}" (use e.g. http://127.0.0.1:8080/health)`);
|
|
1034
1170
|
}
|
|
1035
|
-
|
|
1171
|
+
// URL.hostname keeps brackets for IPv6 literals ("[::1]"); normalize for the
|
|
1172
|
+
// allowlist check and for http.request.
|
|
1173
|
+
const host = u.hostname.replace(/^\[|\]$/g, "");
|
|
1174
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(host)) {
|
|
1036
1175
|
throw new Error(
|
|
1037
1176
|
`docker_curl: only host-local published ports are reachable from the host process ` +
|
|
1038
1177
|
`(sbx binds 127.0.0.1; tried host "${u.hostname}"). For the sandbox-internal address use docker_exec.`,
|
|
1039
1178
|
);
|
|
1040
1179
|
}
|
|
1180
|
+
const port = Number(u.port || 80);
|
|
1181
|
+
// Confine to ports THIS sandbox actually published — docker_curl is for
|
|
1182
|
+
// verifying a deployed container, not a general host-localhost HTTP client
|
|
1183
|
+
// (which could otherwise probe unrelated host services on localhost).
|
|
1184
|
+
const published = await publishedHostPorts();
|
|
1185
|
+
if (!published.has(port)) {
|
|
1186
|
+
throw new Error(
|
|
1187
|
+
`docker_curl: port ${port} is not published by this sandbox. ` +
|
|
1188
|
+
`Published host ports: ${published.size ? [...published].sort((a, b) => a - b).join(", ") : "(none)"}. ` +
|
|
1189
|
+
`Start a container with docker_run(ports=[...]) or docker_compose up first.`,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1041
1192
|
const meth = (method || "GET").toUpperCase();
|
|
1193
|
+
if (!ALLOWED_METHODS.has(meth)) {
|
|
1194
|
+
throw new Error(`docker_curl: unsupported method "${method}" (allowed: ${[...ALLOWED_METHODS].join(", ")})`);
|
|
1195
|
+
}
|
|
1042
1196
|
const hasBody = body !== undefined;
|
|
1197
|
+
if (hasBody && Buffer.byteLength(body ?? "") > MAX_CURL_BODY) {
|
|
1198
|
+
throw new Error(`docker_curl: body exceeds ${MAX_CURL_BODY} bytes`);
|
|
1199
|
+
}
|
|
1200
|
+
const timeoutMs = (Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 10) * 1000;
|
|
1043
1201
|
const result = await new Promise<{ status: number; headers: http.IncomingHttpHeaders; text: string }>((resolve, reject) => {
|
|
1044
1202
|
const req = http.request(
|
|
1045
1203
|
{
|
|
1046
|
-
host
|
|
1047
|
-
port
|
|
1204
|
+
host,
|
|
1205
|
+
port,
|
|
1048
1206
|
path: `${u.pathname}${u.search}`,
|
|
1049
1207
|
method: meth,
|
|
1050
|
-
timeout:
|
|
1208
|
+
timeout: timeoutMs,
|
|
1209
|
+
...(host.includes(":") ? { family: 6 } : {}),
|
|
1051
1210
|
headers: {
|
|
1052
1211
|
accept: "*/*",
|
|
1053
1212
|
"user-agent": "pi-docker-sandbox/1",
|
|
@@ -1085,6 +1244,10 @@ async function toolInit(
|
|
|
1085
1244
|
return `docker_init: ${path.join(dir, "Dockerfile")} already exists (pass force=true to overwrite).`;
|
|
1086
1245
|
}
|
|
1087
1246
|
|
|
1247
|
+
const LANGS = new Set(["node", "pnpm", "go", "python", "rust", "generic"]);
|
|
1248
|
+
if (opts.lang && !LANGS.has(opts.lang.toLowerCase())) {
|
|
1249
|
+
throw new Error(`docker_init: unknown lang "${opts.lang}" (use node|pnpm|go|python|rust|generic)`);
|
|
1250
|
+
}
|
|
1088
1251
|
const lang: Lang = opts.lang ? (opts.lang.toLowerCase() as Lang) : detectLang(dir);
|
|
1089
1252
|
const port = opts.lang === "go" || lang === "go" ? 8080 : lang === "python" ? 8000 : lang === "generic" ? 8080 : 3000;
|
|
1090
1253
|
|
|
@@ -1119,15 +1282,18 @@ async function toolVerify(): Promise<string> {
|
|
|
1119
1282
|
// DOCKER_* sentinel is injected into the HOST process env; runSbxCli's
|
|
1120
1283
|
// scrubber must strip it before the sbx exec child (and thus the sandbox)
|
|
1121
1284
|
// ever sees it. If the scrubber regresses, the sentinel leaks and fails.
|
|
1122
|
-
|
|
1285
|
+
// The sentinel name is unique per call so concurrent verifies cannot
|
|
1286
|
+
// interfere with each other's probe.
|
|
1287
|
+
const sentinel = `DOCKER_HOST_SENTINEL_${Math.random().toString(36).slice(2, 8)}`;
|
|
1288
|
+
env[sentinel] = "sbx-scrub-probe";
|
|
1123
1289
|
let envOk = false;
|
|
1124
1290
|
let envOut = "";
|
|
1125
1291
|
try {
|
|
1126
1292
|
const envCheck = await runSbxCli(["exec", name, "--", "sh", "-c", "env | grep -iE '^(DOCKER_|COMPOSE_)' || echo __CLEAN__"]);
|
|
1127
1293
|
envOut = `${envCheck.stdout}\n${envCheck.stderr}`.trim();
|
|
1128
|
-
envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes(
|
|
1294
|
+
envOk = envCheck.code === 0 && envOut.includes("__CLEAN__") && !envOut.includes(sentinel);
|
|
1129
1295
|
} finally {
|
|
1130
|
-
delete env
|
|
1296
|
+
delete env[sentinel];
|
|
1131
1297
|
}
|
|
1132
1298
|
results.push({
|
|
1133
1299
|
check: "env: no DOCKER_*/COMPOSE_* variables leak into the sandbox",
|
|
@@ -1140,15 +1306,16 @@ async function toolVerify(): Promise<string> {
|
|
|
1140
1306
|
// reach the sandbox (probe technique, but for a NON-docker var so it is
|
|
1141
1307
|
// subject to the allowlist gate, not just the docker scrub).
|
|
1142
1308
|
if (!envPassthrough()) {
|
|
1143
|
-
|
|
1309
|
+
const probeName = `__PI_DOCKER_SANDBOX_VERIFY_PROBE_${Math.random().toString(36).slice(2, 8)}__`;
|
|
1310
|
+
env[probeName] = "sbx-env-probe";
|
|
1144
1311
|
let probeOk = false;
|
|
1145
1312
|
let probeOut = "";
|
|
1146
1313
|
try {
|
|
1147
|
-
const probe = await runSbxCli(["exec", name, "--", "sh", "-c",
|
|
1314
|
+
const probe = await runSbxCli(["exec", name, "--", "sh", "-c", `env | grep -F ${probeName} || echo __PROBE_ABSENT__`]);
|
|
1148
1315
|
probeOut = `${probe.stdout}\n${probe.stderr}`.trim();
|
|
1149
|
-
probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes(
|
|
1316
|
+
probeOk = probe.code === 0 && probeOut.includes("__PROBE_ABSENT__") && !probeOut.includes(probeName);
|
|
1150
1317
|
} finally {
|
|
1151
|
-
delete env
|
|
1318
|
+
delete env[probeName];
|
|
1152
1319
|
}
|
|
1153
1320
|
results.push({
|
|
1154
1321
|
check: "env: restricted forwarding (allowlist/strict) — non-allowlisted vars do not reach the sandbox",
|
|
@@ -1229,14 +1396,15 @@ async function toolVerify(): Promise<string> {
|
|
|
1229
1396
|
}
|
|
1230
1397
|
results.push(sockResult);
|
|
1231
1398
|
|
|
1232
|
-
// 7. port bindings are host-localhost only
|
|
1233
|
-
|
|
1234
|
-
const
|
|
1235
|
-
const
|
|
1399
|
+
// 7. port bindings are host-localhost only (inspect the actual `sbx ports`
|
|
1400
|
+
// mappings rather than the `sbx ls` row, which may not render ports).
|
|
1401
|
+
const portsList = await runSbxCli(["ports", name]);
|
|
1402
|
+
const portLines = portsList.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1403
|
+
const nonLocal = portLines.some((l) => !/^127\.0\.0\.1\s/.test(l) && /\d+\s+\d+\s+(tcp|udp)/.test(l));
|
|
1236
1404
|
results.push({
|
|
1237
1405
|
check: "network: published ports bind to host 127.0.0.1 only",
|
|
1238
1406
|
ok: !nonLocal,
|
|
1239
|
-
evidence: nonLocal ?
|
|
1407
|
+
evidence: nonLocal ? portLines.filter((l) => !/^127\.0\.0\.1\s/.test(l)).join("; ") : (portLines.join("; ") || "no published ports currently"),
|
|
1240
1408
|
});
|
|
1241
1409
|
|
|
1242
1410
|
const failed = results.filter((r) => !r.ok);
|
|
@@ -1269,6 +1437,8 @@ function keepalive(): boolean {
|
|
|
1269
1437
|
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
1270
1438
|
}
|
|
1271
1439
|
|
|
1440
|
+
let watchdogArmed = false;
|
|
1441
|
+
|
|
1272
1442
|
/**
|
|
1273
1443
|
* Arm a detached watchdog that tears the sandbox down when THIS pi process
|
|
1274
1444
|
* exits — works even for SIGKILL/power kills that never fire session_shutdown.
|
|
@@ -1288,6 +1458,10 @@ function spawnWatchdog(): void {
|
|
|
1288
1458
|
// applies even when teardown is "none" — a pinned/shared sandbox is exactly
|
|
1289
1459
|
// the case where you want the VM kept alive but NOT removed.
|
|
1290
1460
|
if (mode === "none" && !keep) return;
|
|
1461
|
+
// Arm once per process: session_start and ensureSandbox both call this, and
|
|
1462
|
+
// a second watchdog would just duplicate the same teardown/keepalive work.
|
|
1463
|
+
if (watchdogArmed) return;
|
|
1464
|
+
watchdogArmed = true;
|
|
1291
1465
|
const op = mode === "stop" ? `stop ${name}` : `rm --force ${name}`;
|
|
1292
1466
|
const keepLine = keep ? `if [ $((i % 12)) -eq 0 ]; then ${findSbxCli()} ls 2>/dev/null | grep -Fq ${name} && ${findSbxCli()} exec ${name} -- true 2>/dev/null; fi` : "";
|
|
1293
1467
|
const lines = [
|
|
@@ -1415,10 +1589,18 @@ async function gcSweep(hours: number): Promise<string> {
|
|
|
1415
1589
|
for (const b of boxes) {
|
|
1416
1590
|
const n = b.name;
|
|
1417
1591
|
if (!n || !n.startsWith("pi-sbx-") || n === current) continue;
|
|
1418
|
-
|
|
1592
|
+
const status = (b.status ?? "").trim().toLowerCase();
|
|
1593
|
+
if (status === "running") {
|
|
1419
1594
|
kept.push(`${n} (running)`);
|
|
1420
1595
|
continue;
|
|
1421
1596
|
}
|
|
1597
|
+
// Conservative: only remove sandboxes we can positively identify as
|
|
1598
|
+
// stopped. Unknown/unexpected status strings are kept (never removed) —
|
|
1599
|
+
// a misread status must not cause a running sandbox to be reaped.
|
|
1600
|
+
if (status !== "stopped") {
|
|
1601
|
+
kept.push(`${n} (status "${b.status ?? "?"}" — kept)`);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1422
1604
|
// Sibling guard: never remove a sandbox whose owner pi process is alive
|
|
1423
1605
|
// (it may be idle with an idle-stopped VM — concurrent sessions must not
|
|
1424
1606
|
// reap each other). Only stop-orphaned sandboxes are candidates.
|
|
@@ -1462,6 +1644,36 @@ function textResult(text: string): AgentToolResult<undefined> {
|
|
|
1462
1644
|
/* extension registration */
|
|
1463
1645
|
/* ------------------------------------------------------------------ */
|
|
1464
1646
|
|
|
1647
|
+
let lifecycleArmed = false;
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Arm everything that must outlive a session: the detached watchdog (teardown
|
|
1651
|
+
* on pi exit + keepalive pokes while pi lives), the owner marker that stops GC
|
|
1652
|
+
* reclaiming a live sandbox, and the startup sweep of stale sandboxes.
|
|
1653
|
+
*
|
|
1654
|
+
* Idempotent: the docker_* extension and the sandbox execution backend both
|
|
1655
|
+
* call this from their own session_start, and loading both must not double-arm.
|
|
1656
|
+
*
|
|
1657
|
+
* Called separately from ensureSandbox() on purpose — ensureSandbox returns
|
|
1658
|
+
* early when the sandbox ALREADY exists (a pinned DOCKER_SANDBOX, a resumed
|
|
1659
|
+
* session), so arming only there would silently skip keepalive/teardown for
|
|
1660
|
+
* every sandbox that was not created by this process.
|
|
1661
|
+
*/
|
|
1662
|
+
async function armSessionLifecycle(): Promise<void> {
|
|
1663
|
+
if (lifecycleArmed) return;
|
|
1664
|
+
lifecycleArmed = true;
|
|
1665
|
+
spawnWatchdog();
|
|
1666
|
+
writeOwnerMarker(sessionSandboxName());
|
|
1667
|
+
const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
|
|
1668
|
+
if (!Number.isFinite(raw) || raw <= 0) return;
|
|
1669
|
+
// Deliberately NOT awaited: the sweep is a background safety net for stale
|
|
1670
|
+
// sandboxes from crashed sessions, and session start must not wait on
|
|
1671
|
+
// `sbx ls` (and any sandboxd round trip) to get there.
|
|
1672
|
+
void gcSweep(raw)
|
|
1673
|
+
.then((summary) => console.error(`[docker-sandbox] ${summary}`))
|
|
1674
|
+
.catch((e) => console.error(`[docker-sandbox] gc at startup failed: ${(e as Error).message}`));
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1465
1677
|
export default function (pi: ExtensionAPI) {
|
|
1466
1678
|
// Lifecycle: tear down this session's sandbox when the session ends
|
|
1467
1679
|
// (exit / Ctrl+C / Ctrl+D / SIGHUP / SIGTERM, /new, /resume, /fork).
|
|
@@ -1470,18 +1682,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1470
1682
|
});
|
|
1471
1683
|
|
|
1472
1684
|
// Crash safety net: sweep stale pi-sbx-* sandboxes at session start, and
|
|
1473
|
-
// arm the watchdog + owner marker for the current sandbox name (covers
|
|
1474
|
-
// /resume case).
|
|
1685
|
+
// arm the watchdog + owner marker for the current sandbox name (covers the
|
|
1686
|
+
// /resume case). Shared with the sandbox execution backend, which needs the
|
|
1687
|
+
// same lifecycle when it is loaded on its own.
|
|
1475
1688
|
pi.on("session_start", async () => {
|
|
1476
|
-
|
|
1477
|
-
writeOwnerMarker(sessionSandboxName());
|
|
1478
|
-
const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
|
|
1479
|
-
if (!Number.isFinite(raw) || raw <= 0) return;
|
|
1480
|
-
try {
|
|
1481
|
-
console.error(`[docker-sandbox] ${await gcSweep(raw)}`);
|
|
1482
|
-
} catch (e) {
|
|
1483
|
-
console.error(`[docker-sandbox] gc at startup failed: ${(e as Error).message}`);
|
|
1484
|
-
}
|
|
1689
|
+
await armSessionLifecycle();
|
|
1485
1690
|
});
|
|
1486
1691
|
|
|
1487
1692
|
pi.registerTool({
|
|
@@ -1672,7 +1877,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1672
1877
|
"publishes container ports on host 127.0.0.1 only, and this runs in the host pi process, so it is the way " +
|
|
1673
1878
|
"to check a running service from the agent (the agent's VM cannot reach host loopback). " +
|
|
1674
1879
|
"url: e.g. http://127.0.0.1:8080/health. method: GET (default), POST, PUT, etc. body: optional request body " +
|
|
1675
|
-
"(content-type application/json). Only 127.0.0.1/localhost/::1 hosts
|
|
1880
|
+
"(content-type application/json). Only 127.0.0.1/localhost/::1 hosts AND ports published by this sandbox " +
|
|
1881
|
+
"are reachable. Returns status + body.",
|
|
1676
1882
|
parameters: Type.Object({
|
|
1677
1883
|
url: Type.String({ description: "Host-local URL of the published port, e.g. http://127.0.0.1:8080/health" }),
|
|
1678
1884
|
timeoutSec: Type.Optional(Type.Number({ description: "Timeout in seconds (default 10)" })),
|
|
@@ -1749,4 +1955,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1749
1955
|
}
|
|
1750
1956
|
|
|
1751
1957
|
// Named exports for tests (pi's loader only calls the default factory).
|
|
1752
|
-
|
|
1958
|
+
// The sbx kernel is also shared with the `sandbox/` execution-backend
|
|
1959
|
+
// extension (host pi + tools routed into the sandbox), so that the sandbox
|
|
1960
|
+
// lifecycle, env scrubbing and path confinement exist in exactly one place.
|
|
1961
|
+
export {
|
|
1962
|
+
scrubbedEnv,
|
|
1963
|
+
envForwardMode,
|
|
1964
|
+
envAllowlist,
|
|
1965
|
+
envPassthrough,
|
|
1966
|
+
sessionSandboxName,
|
|
1967
|
+
mapHostPath,
|
|
1968
|
+
assertSafeArg,
|
|
1969
|
+
hostRoot,
|
|
1970
|
+
findSbxCli,
|
|
1971
|
+
runSbxCli,
|
|
1972
|
+
sandboxExists,
|
|
1973
|
+
ensureSandbox,
|
|
1974
|
+
teardownSandbox,
|
|
1975
|
+
armSessionLifecycle,
|
|
1976
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stixxert/pi-docker-sandbox",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -10,16 +10,33 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/stixxert/pi-docker-sandbox.git"
|
|
12
12
|
},
|
|
13
|
-
"files": [
|
|
14
|
-
|
|
13
|
+
"files": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"sandbox/",
|
|
16
|
+
"template/",
|
|
17
|
+
"test-loader.mjs",
|
|
18
|
+
"README.md",
|
|
19
|
+
"boundary.md",
|
|
20
|
+
"security.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"pi-package"
|
|
25
|
+
],
|
|
15
26
|
"type": "module",
|
|
16
27
|
"scripts": {
|
|
17
28
|
"typecheck": "tsc --noEmit",
|
|
18
29
|
"test": "node smoke-test.mjs",
|
|
19
|
-
"
|
|
30
|
+
"e2e": "node sandbox/e2e.mjs",
|
|
31
|
+
"template": "bash template/build.sh",
|
|
32
|
+
"template:check": "bash template/build.sh --check",
|
|
33
|
+
"prepublishOnly": "npm run typecheck && npm test",
|
|
34
|
+
"release": "semantic-release"
|
|
20
35
|
},
|
|
21
36
|
"pi": {
|
|
22
|
-
"extensions": [
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./index.ts"
|
|
39
|
+
]
|
|
23
40
|
},
|
|
24
41
|
"peerDependencies": {
|
|
25
42
|
"@earendil-works/pi-ai": "*",
|
|
@@ -29,6 +46,7 @@
|
|
|
29
46
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
30
47
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
31
48
|
"@types/node": "^24.0.0",
|
|
49
|
+
"semantic-release": "^25.0.9",
|
|
32
50
|
"typescript": "^5.9.0"
|
|
33
51
|
}
|
|
34
52
|
}
|