@stixxert/pi-docker-sandbox 1.0.1 → 1.1.1
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 +41 -0
- package/boundary.md +10 -0
- package/index.ts +74 -16
- package/package.json +7 -1
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +469 -0
- package/sandbox/index.ts +232 -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 +40 -1
- 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/sandbox/e2e.mjs
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test for the sbx execution backend.
|
|
3
|
+
*
|
|
4
|
+
* `sbx` itself cannot run here: it boots microVMs through a host hypervisor and
|
|
5
|
+
* this machine is already a sandbox VM with no nested virtualization
|
|
6
|
+
* (/dev/kvm is absent). The transport is therefore pluggable, and the
|
|
7
|
+
* *identical* ops layer is driven here against a real container via
|
|
8
|
+
* `docker exec` — the only difference from the product path is which binary
|
|
9
|
+
* runs `exec <target> --`.
|
|
10
|
+
*
|
|
11
|
+
* What this proves:
|
|
12
|
+
* - the workspace is visible at the SAME absolute path inside the sandbox
|
|
13
|
+
* (the assumption the whole design rests on)
|
|
14
|
+
* - bash runs in the sandbox and propagates exit codes
|
|
15
|
+
* - read/write move arbitrary bytes with no stdin dependency
|
|
16
|
+
* - ls/find/grep return correct results through pi's routed tools
|
|
17
|
+
* - the extension registers exactly the 7 built-in names — no new tools, so
|
|
18
|
+
* zero added prompt cost — and each one executes end-to-end
|
|
19
|
+
* - when the sandbox is unavailable, pi degrades to local tools instead of
|
|
20
|
+
* breaking (sbx is genuinely absent here, so that path is exercised for real)
|
|
21
|
+
*
|
|
22
|
+
* Run: node sandbox/e2e.mjs (requires docker)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { loadTs } from "../test-loader.mjs";
|
|
30
|
+
|
|
31
|
+
const CONTAINER = `sbx-e2e-${process.pid}`;
|
|
32
|
+
const IMAGE = process.env.SBX_E2E_IMAGE ?? "debian:stable-slim";
|
|
33
|
+
const workdir = fs.mkdtempSync(path.join("/tmp", "sbx-e2e-"));
|
|
34
|
+
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
35
|
+
// Stand in the project directory, exactly as pi would be launched there: the
|
|
36
|
+
// extension captures process.cwd() as the workspace root.
|
|
37
|
+
process.chdir(workdir);
|
|
38
|
+
|
|
39
|
+
let failures = 0;
|
|
40
|
+
function check(name, condition, detail = "") {
|
|
41
|
+
if (!condition) failures++;
|
|
42
|
+
const suffix = condition || !detail ? "" : ` — ${detail}`;
|
|
43
|
+
console.log(` [${condition ? "PASS" : "FAIL"}] ${name}${suffix}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function docker(args, opts = {}) {
|
|
47
|
+
return execFileSync("docker", args, { encoding: "utf8", ...opts });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------------ */
|
|
51
|
+
/* TypeScript loading: Node >= 23.6 strips types natively; this sandbox */
|
|
52
|
+
/* runs Node 22, so fall back to the loader pi itself uses (see */
|
|
53
|
+
/* ../test-loader.mjs). */
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
|
|
56
|
+
/** Drive the extension factory with a mock pi and return its tools by name. */
|
|
57
|
+
function collectTools(factory) {
|
|
58
|
+
const registered = [];
|
|
59
|
+
const commands = [];
|
|
60
|
+
factory({
|
|
61
|
+
registerTool: (def) => registered.push(def),
|
|
62
|
+
registerCommand: (name, def) => commands.push(name),
|
|
63
|
+
on: () => {},
|
|
64
|
+
});
|
|
65
|
+
return { registered, commands, byName: Object.fromEntries(registered.map((t) => [t.name, t])) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const asText = (result) => JSON.stringify(result?.content ?? result);
|
|
69
|
+
|
|
70
|
+
/* ------------------------------------------------------------------ */
|
|
71
|
+
|
|
72
|
+
console.log(`\n=== sbx backend e2e ===`);
|
|
73
|
+
console.log(`workspace: ${workdir}`);
|
|
74
|
+
console.log(`image: ${IMAGE}`);
|
|
75
|
+
|
|
76
|
+
const { createDockerTransport, resolveTransport, templateListHas } = await loadTs("sandbox/transport.ts");
|
|
77
|
+
const { createReadOps, createWriteOps, createLsOps, createBashOps } = await loadTs("sandbox/operations.ts");
|
|
78
|
+
const { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool } =
|
|
79
|
+
await import("@earendil-works/pi-coding-agent");
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
docker(["rm", "-f", CONTAINER], { stdio: "ignore" });
|
|
83
|
+
docker(["run", "-d", "--name", CONTAINER, "-v", `${workdir}:${workdir}`, "-w", workdir, IMAGE, "sleep", "600"]);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`could not start the test container (is docker available?): ${err.message}`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
process.env.SBX_BACKEND = "docker";
|
|
91
|
+
process.env.SBX_DOCKER_CONTAINER = CONTAINER;
|
|
92
|
+
|
|
93
|
+
const transport = createDockerTransport(CONTAINER);
|
|
94
|
+
const extension = (await loadTs("sandbox/index.ts")).default;
|
|
95
|
+
const { registered, commands, byName } = collectTools(extension);
|
|
96
|
+
|
|
97
|
+
/* --- registration ------------------------------------------------ */
|
|
98
|
+
console.log("\nregistration (zero added prompt cost)");
|
|
99
|
+
const expectedNames = ["bash", "edit", "find", "grep", "ls", "read", "write"];
|
|
100
|
+
check("registers exactly the 7 built-in tool names", JSON.stringify(registered.map((t) => t.name).sort()) === JSON.stringify(expectedNames), registered.map((t) => t.name).join(","));
|
|
101
|
+
check("adds no new tool schemas", registered.length === expectedNames.length, `${registered.length} tools`);
|
|
102
|
+
check("registers the /sbx status command", commands.includes("sbx"), commands.join(","));
|
|
103
|
+
// Regression guard: an override built from createXTool() (wrapped AgentTool)
|
|
104
|
+
// silently loses these, which deletes the built-in tool guidance from the
|
|
105
|
+
// system prompt. pi builds that table from the registered tool objects.
|
|
106
|
+
const missingGuidance = registered.filter((t) => !t.promptSnippet).map((t) => t.name);
|
|
107
|
+
check("every override keeps its prompt snippet", missingGuidance.length === 0, `missing on: ${missingGuidance.join(",")}`);
|
|
108
|
+
check("prompt guidelines survive the override", (byName.edit.promptGuidelines ?? []).length > 0, JSON.stringify(byName.edit.promptGuidelines));
|
|
109
|
+
|
|
110
|
+
/* --- the mount assumption ---------------------------------------- */
|
|
111
|
+
console.log("\nsame-path mount (the assumption the design rests on)");
|
|
112
|
+
const pwd = await byName.bash.execute("t1", { command: "pwd" }, undefined, undefined, undefined);
|
|
113
|
+
check("sandbox cwd equals the host path", pwd.content[0].text.trim() === workdir, pwd.content[0].text.trim());
|
|
114
|
+
|
|
115
|
+
fs.writeFileSync(path.join(workdir, "from-host.txt"), "written on the host\n");
|
|
116
|
+
const seen = await byName.bash.execute("t2", { command: "cat from-host.txt", timeout: 30 }, undefined, undefined, undefined);
|
|
117
|
+
check("sandbox sees a host-written file", asText(seen).includes("written on the host"));
|
|
118
|
+
|
|
119
|
+
const uname = await byName.bash.execute("t3", { command: "uname -s" }, undefined, undefined, undefined);
|
|
120
|
+
check("bash runs in Linux, not on the host", uname.content[0].text.trim() === "Linux", uname.content[0].text.trim());
|
|
121
|
+
|
|
122
|
+
/* --- exit codes --------------------------------------------------- */
|
|
123
|
+
console.log("\nexit codes");
|
|
124
|
+
let exitOk = false;
|
|
125
|
+
try {
|
|
126
|
+
const bad = await byName.bash.execute("t4", { command: "exit 42" }, undefined, undefined, undefined);
|
|
127
|
+
exitOk = /42/.test(asText(bad));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
exitOk = /42/.test(String(err.message));
|
|
130
|
+
}
|
|
131
|
+
check("a non-zero exit is surfaced to the model", exitOk);
|
|
132
|
+
|
|
133
|
+
/* --- binary-safe file reads --------------------------------------- */
|
|
134
|
+
console.log("\nfile primitives (base64 over argv, no stdin)");
|
|
135
|
+
// The write *interface* is string/UTF-8 (identical to pi's local write tool),
|
|
136
|
+
// so binary fidelity lives on the read path — which is what images use.
|
|
137
|
+
const bytes = Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x80, 0x7f, 0x0a, 0x0d, 0xc3, 0xa9]);
|
|
138
|
+
const binPath = path.join(workdir, "binary.bin");
|
|
139
|
+
fs.writeFileSync(binPath, bytes); // the mount makes this visible inside the sandbox
|
|
140
|
+
const roundTrip = await createReadOps(transport).readFile(binPath);
|
|
141
|
+
check("arbitrary bytes read byte-exact", Buffer.compare(roundTrip, bytes) === 0, `got ${roundTrip.toString("hex")} want ${bytes.toString("hex")}`);
|
|
142
|
+
check(
|
|
143
|
+
"image mime detection drives the read tool's image path",
|
|
144
|
+
(await createReadOps(transport).detectImageMimeType?.(path.join(workdir, "x.png"))) === "image/png",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const big = "abcdefghij".repeat(200_000); // 2 MB => several argv-sized chunks
|
|
148
|
+
const bigPath = path.join(workdir, "big.txt");
|
|
149
|
+
await createWriteOps(transport).writeFile(bigPath, big);
|
|
150
|
+
check("multi-chunk large write is byte-exact", fs.readFileSync(bigPath, "utf8") === big, `${fs.statSync(bigPath).size} bytes`);
|
|
151
|
+
|
|
152
|
+
/* --- routed pi tools ---------------------------------------------- */
|
|
153
|
+
console.log("\nrouted pi tools");
|
|
154
|
+
check("read tool returns file contents", asText(await byName.read.execute("t5", { path: path.join(workdir, "from-host.txt") }, undefined, undefined, undefined)).includes("written on the host"));
|
|
155
|
+
|
|
156
|
+
const editResult = await byName.edit.execute(
|
|
157
|
+
"t6",
|
|
158
|
+
{ path: path.join(workdir, "from-host.txt"), edits: [{ oldText: "written on the host", newText: "edited in the sandbox" }] },
|
|
159
|
+
undefined,
|
|
160
|
+
undefined,
|
|
161
|
+
undefined,
|
|
162
|
+
);
|
|
163
|
+
check("edit tool applied the change", fs.readFileSync(path.join(workdir, "from-host.txt"), "utf8").includes("edited in the sandbox"), asText(editResult).slice(0, 160));
|
|
164
|
+
|
|
165
|
+
check("ls lists sandbox entries", asText(await byName.ls.execute("t7", { path: workdir }, undefined, undefined, undefined)).includes("from-host.txt"));
|
|
166
|
+
check("find matches a glob", asText(await byName.find.execute("t8", { pattern: "*.txt", path: workdir }, undefined, undefined, undefined)).includes("big.txt"));
|
|
167
|
+
check("grep finds a pattern", asText(await byName.grep.execute("t9", { pattern: "edited in the sandbox", path: workdir }, undefined, undefined, undefined)).includes("edited in the sandbox"));
|
|
168
|
+
|
|
169
|
+
/* --- grep really runs inside the sandbox -------------------------- */
|
|
170
|
+
console.log("\ngrep executes in the sandbox, not via host ripgrep");
|
|
171
|
+
// A directory that exists ONLY inside the container's own filesystem.
|
|
172
|
+
const sandboxOnly = `/opt/sbx-e2e-only-${process.pid}`;
|
|
173
|
+
const marker = `marker-${process.pid}`;
|
|
174
|
+
await byName.bash.execute(
|
|
175
|
+
"g1",
|
|
176
|
+
{ command: `mkdir -p '${sandboxOnly}' && printf '%s\\n' '${marker}' > '${sandboxOnly}/inside.txt'` },
|
|
177
|
+
undefined,
|
|
178
|
+
undefined,
|
|
179
|
+
undefined,
|
|
180
|
+
);
|
|
181
|
+
check("the sandbox-only path is invisible to the host", !fs.existsSync(sandboxOnly), `host has ${sandboxOnly}`);
|
|
182
|
+
const sandboxGrep = await byName.grep.execute("g2", { pattern: marker, path: sandboxOnly }, undefined, undefined, undefined);
|
|
183
|
+
check("grep finds a file only the sandbox can see", asText(sandboxGrep).includes(marker), asText(sandboxGrep).slice(0, 200));
|
|
184
|
+
|
|
185
|
+
const contextGrep = await byName.grep.execute(
|
|
186
|
+
"g3",
|
|
187
|
+
{ pattern: marker, path: sandboxOnly, context: 1, literal: true },
|
|
188
|
+
undefined,
|
|
189
|
+
undefined,
|
|
190
|
+
undefined,
|
|
191
|
+
);
|
|
192
|
+
check("literal + context grep returns the match", asText(contextGrep).includes("inside.txt"), asText(contextGrep).slice(0, 200));
|
|
193
|
+
|
|
194
|
+
/* --- sandbox env hygiene ------------------------------------------ */
|
|
195
|
+
console.log("\nsandbox env hygiene");
|
|
196
|
+
const secret = `leak-me-${process.pid}`;
|
|
197
|
+
process.env.SBX_E2E_SECRET = secret;
|
|
198
|
+
const leaked = await byName.bash.execute("e1", { command: "printf '%s' \"$SBX_E2E_SECRET\"" }, undefined, undefined, undefined);
|
|
199
|
+
check("host secrets are NOT exported into the sandbox", !leaked.content[0].text.includes(secret), leaked.content[0].text.slice(0, 80));
|
|
200
|
+
|
|
201
|
+
let captured = "";
|
|
202
|
+
await createBashOps(transport, { allowEnv: (name) => name.startsWith("PI_") }).exec(
|
|
203
|
+
"printf '%s' \"$PI_MODEL:$SBX_E2E_SECRET\"",
|
|
204
|
+
workdir,
|
|
205
|
+
{
|
|
206
|
+
onData: (chunk) => {
|
|
207
|
+
captured += chunk.toString();
|
|
208
|
+
},
|
|
209
|
+
env: { PI_MODEL: "kept", SBX_E2E_SECRET: secret },
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
check("PI_* session metadata is forwarded", captured.includes("kept"), captured);
|
|
213
|
+
check("non-PI environment is dropped", !captured.includes(secret), captured);
|
|
214
|
+
delete process.env.SBX_E2E_SECRET;
|
|
215
|
+
|
|
216
|
+
/* --- find pruning -------------------------------------------------- */
|
|
217
|
+
console.log("\nfind pruning");
|
|
218
|
+
const nmDir = path.join(workdir, "node_modules", "pkg");
|
|
219
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
220
|
+
fs.writeFileSync(path.join(nmDir, "buried.txt"), "x");
|
|
221
|
+
const pruned = await byName.find.execute("f1", { pattern: "buried.txt", path: workdir }, undefined, undefined, undefined);
|
|
222
|
+
check("find does not enumerate node_modules", !asText(pruned).includes("buried.txt"), asText(pruned).slice(0, 160));
|
|
223
|
+
|
|
224
|
+
/* --- .gitignore fidelity + path errors ---------------------------- */
|
|
225
|
+
console.log("\n.gitignore fidelity (git-index enumeration)");
|
|
226
|
+
let gitAvailable = true;
|
|
227
|
+
try {
|
|
228
|
+
docker(["exec", CONTAINER, "sh", "-c", "apt-get update -qq && apt-get install -y -qq git"], { stdio: "ignore" });
|
|
229
|
+
} catch {
|
|
230
|
+
gitAvailable = false;
|
|
231
|
+
console.log(" (git unavailable in the test image — skipping the git-index assertions)");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fs.mkdirSync(path.join(workdir, "ignored"), { recursive: true });
|
|
235
|
+
fs.writeFileSync(path.join(workdir, ".gitignore"), "ignored/\nnode_modules/\n*.log\n");
|
|
236
|
+
fs.writeFileSync(path.join(workdir, "ignored", "secret.txt"), "hidden-needle\n");
|
|
237
|
+
fs.writeFileSync(path.join(workdir, "debug.log"), "hidden-needle\n");
|
|
238
|
+
fs.writeFileSync(path.join(workdir, "visible.txt"), "hidden-needle\n");
|
|
239
|
+
if (gitAvailable) {
|
|
240
|
+
await byName.bash.execute("gi0", { command: "git init -q" }, undefined, undefined, undefined);
|
|
241
|
+
|
|
242
|
+
const ignoredGrep = await byName.grep.execute("gi1", { pattern: "hidden-needle", path: workdir }, undefined, undefined, undefined);
|
|
243
|
+
const grepText = asText(ignoredGrep);
|
|
244
|
+
check("grep finds the non-ignored file", grepText.includes("visible.txt"), grepText.slice(0, 200));
|
|
245
|
+
check("grep skips a .gitignore'd directory", !grepText.includes("secret.txt"), grepText.slice(0, 200));
|
|
246
|
+
check("grep skips a .gitignore'd file pattern", !grepText.includes("debug.log"), grepText.slice(0, 200));
|
|
247
|
+
|
|
248
|
+
const ignoredFind = await byName.find.execute("gi2", { pattern: "*.txt", path: workdir }, undefined, undefined, undefined);
|
|
249
|
+
const findText = asText(ignoredFind);
|
|
250
|
+
check("find honours .gitignore too", findText.includes("visible.txt") && !findText.includes("secret.txt"), findText.slice(0, 200));
|
|
251
|
+
check("find no longer surfaces node_modules", !findText.includes("buried.txt"), findText.slice(0, 200));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let missingError = "no-error";
|
|
255
|
+
try {
|
|
256
|
+
await byName.grep.execute("gi3", { pattern: "x", path: path.join(workdir, "does-not-exist") }, undefined, undefined, undefined);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
missingError = String(err.message);
|
|
259
|
+
}
|
|
260
|
+
check("grep on a missing path errors instead of claiming no matches", /not found/i.test(missingError), missingError.slice(0, 120));
|
|
261
|
+
|
|
262
|
+
/* --- context coalescing ------------------------------------------- */
|
|
263
|
+
console.log("\noverlapping context windows");
|
|
264
|
+
fs.writeFileSync(path.join(workdir, "coalesce.txt"), "needle-1\nneedle-2\nneedle-3\n");
|
|
265
|
+
const coalesced = await byName.grep.execute(
|
|
266
|
+
"c1",
|
|
267
|
+
{ pattern: "needle", path: path.join(workdir, "coalesce.txt"), context: 2 },
|
|
268
|
+
undefined,
|
|
269
|
+
undefined,
|
|
270
|
+
undefined,
|
|
271
|
+
);
|
|
272
|
+
const needleCount = (asText(coalesced).match(/needle-/g) ?? []).length;
|
|
273
|
+
check("each line is emitted once despite overlapping windows", needleCount === 3, `${needleCount} occurrences`);
|
|
274
|
+
|
|
275
|
+
/* --- round-trip economy ------------------------------------------- */
|
|
276
|
+
console.log("\nls round-trip economy");
|
|
277
|
+
const lsDir = path.join(workdir, "many");
|
|
278
|
+
fs.mkdirSync(lsDir, { recursive: true });
|
|
279
|
+
for (let i = 0; i < 25; i++) fs.writeFileSync(path.join(lsDir, `f${i}.txt`), "x");
|
|
280
|
+
let calls = 0;
|
|
281
|
+
const counting = {
|
|
282
|
+
kind: "docker",
|
|
283
|
+
target: CONTAINER,
|
|
284
|
+
exec: (argv, opts) => {
|
|
285
|
+
calls++;
|
|
286
|
+
return transport.exec(argv, opts);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const lsOps = createLsOps(counting);
|
|
290
|
+
await lsOps.exists(lsDir);
|
|
291
|
+
const rootStat = await lsOps.stat(lsDir);
|
|
292
|
+
const entries = await lsOps.readdir(lsDir);
|
|
293
|
+
for (const name of entries) await lsOps.stat(path.join(lsDir, name));
|
|
294
|
+
check("a 25-entry listing costs <= 4 sandbox round-trips", calls <= 4, `${calls} calls`);
|
|
295
|
+
check("listing returned every entry", entries.length === 25, `${entries.length} entries`);
|
|
296
|
+
check("the listed directory is reported as a directory", rootStat.isDirectory() === true);
|
|
297
|
+
check("entries are reported as files", (await lsOps.stat(path.join(lsDir, "f0.txt"))).isDirectory() === false);
|
|
298
|
+
|
|
299
|
+
/* --- degradation: sbx absent -------------------------------------- */
|
|
300
|
+
console.log("\ndegradation when no sandbox is available");
|
|
301
|
+
process.env.SBX_BACKEND = "sbx";
|
|
302
|
+
delete process.env.SBX_DOCKER_CONTAINER;
|
|
303
|
+
delete process.env.PI_SBX_SANDBOX;
|
|
304
|
+
|
|
305
|
+
const degraded = collectTools((await loadTs("sandbox/index.ts")).default);
|
|
306
|
+
check("still registers the 7 tools", degraded.registered.length === expectedNames.length);
|
|
307
|
+
let localOk = false;
|
|
308
|
+
try {
|
|
309
|
+
const out = await degraded.byName.bash.execute("t10", { command: "echo local-fallback" }, undefined, undefined, undefined);
|
|
310
|
+
localOk = asText(out).includes("local-fallback");
|
|
311
|
+
} catch (err) {
|
|
312
|
+
localOk = /local-fallback/.test(String(err.message));
|
|
313
|
+
}
|
|
314
|
+
check("pi keeps working with local tools instead of breaking", localOk);
|
|
315
|
+
check("the degradation is advertised to sibling extensions", process.env.PI_SBX_SANDBOX === undefined);
|
|
316
|
+
|
|
317
|
+
/* --- project-scoped sandbox naming --------------------------------- */
|
|
318
|
+
console.log("\nproject-scoped sandbox naming (the startup-time fix)");
|
|
319
|
+
const { defaultProjectSandbox } = await loadTs("sandbox/transport.ts");
|
|
320
|
+
const fixture = path.join(os.tmpdir(), `sbx-name-${process.pid}`);
|
|
321
|
+
fs.mkdirSync(path.join(fixture, ".git"), { recursive: true });
|
|
322
|
+
fs.mkdirSync(path.join(fixture, "packages", "app"), { recursive: true });
|
|
323
|
+
fs.mkdirSync(path.join(fixture, "other", ".git"), { recursive: true });
|
|
324
|
+
|
|
325
|
+
delete process.env.DOCKER_SANDBOX;
|
|
326
|
+
delete process.env.SBX_EPHEMERAL;
|
|
327
|
+
const first = defaultProjectSandbox(fixture);
|
|
328
|
+
delete process.env.DOCKER_SANDBOX;
|
|
329
|
+
const second = defaultProjectSandbox(fixture);
|
|
330
|
+
check("derives a stable per-project name", Boolean(first) && first === second, `${first} vs ${second}`);
|
|
331
|
+
check("name is a valid sandbox name", /^pi-sbx-[A-Za-z0-9._+-]+-[0-9a-f]{8}$/.test(first ?? ""), String(first));
|
|
332
|
+
|
|
333
|
+
delete process.env.DOCKER_SANDBOX;
|
|
334
|
+
const fromSubdir = defaultProjectSandbox(path.join(fixture, "packages", "app"));
|
|
335
|
+
check("a subdirectory resolves to the SAME sandbox", fromSubdir === first, `${fromSubdir} vs ${first}`);
|
|
336
|
+
|
|
337
|
+
delete process.env.DOCKER_SANDBOX;
|
|
338
|
+
const otherProject = defaultProjectSandbox(path.join(fixture, "other"));
|
|
339
|
+
check("a different project gets a different sandbox", otherProject !== first, `${otherProject} vs ${first}`);
|
|
340
|
+
|
|
341
|
+
process.env.DOCKER_SANDBOX = "my-pinned";
|
|
342
|
+
check("an explicit DOCKER_SANDBOX wins", defaultProjectSandbox(fixture) === "my-pinned", String(process.env.DOCKER_SANDBOX));
|
|
343
|
+
check("an explicit name is never overwritten", process.env.DOCKER_SANDBOX === "my-pinned");
|
|
344
|
+
|
|
345
|
+
delete process.env.DOCKER_SANDBOX;
|
|
346
|
+
process.env.SBX_EPHEMERAL = "1";
|
|
347
|
+
check(
|
|
348
|
+
"SBX_EPHEMERAL=1 restores per-session sandboxes",
|
|
349
|
+
defaultProjectSandbox(fixture) === undefined && process.env.DOCKER_SANDBOX === undefined,
|
|
350
|
+
String(process.env.DOCKER_SANDBOX),
|
|
351
|
+
);
|
|
352
|
+
delete process.env.SBX_EPHEMERAL;
|
|
353
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
354
|
+
|
|
355
|
+
/* --- keepalive + session lifecycle --------------------------------- */
|
|
356
|
+
console.log("\nkeepalive and session lifecycle");
|
|
357
|
+
process.env.SBX_BACKEND = "sbx";
|
|
358
|
+
delete process.env.SBX_DOCKER_CONTAINER;
|
|
359
|
+
delete process.env.DOCKER_SANDBOX_KEEPALIVE;
|
|
360
|
+
try {
|
|
361
|
+
await resolveTransport();
|
|
362
|
+
} catch {
|
|
363
|
+
/* sbx is absent here — expected; the env defaulting happens first */
|
|
364
|
+
}
|
|
365
|
+
check("keepalive defaults ON for the sbx backend", process.env.DOCKER_SANDBOX_KEEPALIVE === "1", String(process.env.DOCKER_SANDBOX_KEEPALIVE));
|
|
366
|
+
|
|
367
|
+
process.env.DOCKER_SANDBOX_KEEPALIVE = "0";
|
|
368
|
+
try {
|
|
369
|
+
await resolveTransport();
|
|
370
|
+
} catch {
|
|
371
|
+
/* expected */
|
|
372
|
+
}
|
|
373
|
+
check("an explicit KEEPALIVE=0 still wins", process.env.DOCKER_SANDBOX_KEEPALIVE === "0", String(process.env.DOCKER_SANDBOX_KEEPALIVE));
|
|
374
|
+
|
|
375
|
+
process.env.DOCKER_SANDBOX_KEEPALIVE = "1";
|
|
376
|
+
process.env.DOCKER_SANDBOX_TEARDOWN = "none"; // no stray teardown attempt
|
|
377
|
+
process.env.DOCKER_SANDBOX_GC_HOURS = "0"; // skip the startup sweep
|
|
378
|
+
process.env.DOCKER_SANDBOX_DEBUG = "1"; // lifecycle notes are debug-gated (silent in the TUI by default)
|
|
379
|
+
const { armSessionLifecycle } = await loadTs("index.ts");
|
|
380
|
+
const lifecycleLog = [];
|
|
381
|
+
const realError = console.error;
|
|
382
|
+
console.error = (...args) => lifecycleLog.push(args.join(" "));
|
|
383
|
+
await armSessionLifecycle();
|
|
384
|
+
await armSessionLifecycle();
|
|
385
|
+
console.error = realError;
|
|
386
|
+
check(
|
|
387
|
+
"session lifecycle arms exactly once (idempotent)",
|
|
388
|
+
lifecycleLog.filter((line) => /watchdog armed/.test(line)).length === 1,
|
|
389
|
+
lifecycleLog.join(" | ").slice(0, 160),
|
|
390
|
+
);
|
|
391
|
+
delete process.env.DOCKER_SANDBOX_TEARDOWN;
|
|
392
|
+
delete process.env.DOCKER_SANDBOX_GC_HOURS;
|
|
393
|
+
delete process.env.DOCKER_SANDBOX_DEBUG;
|
|
394
|
+
|
|
395
|
+
/* --- lightweight template handshake -------------------------------- */
|
|
396
|
+
console.log("\nlightweight template handshake");
|
|
397
|
+
const tag = "pi-sbx-lite:1a2b3c4d";
|
|
398
|
+
const fullRef = `docker.io/library/${tag}`;
|
|
399
|
+
// Both `sbx template ls` layouts the existing sbxpi detector had to cope with.
|
|
400
|
+
const tableLayout = [
|
|
401
|
+
"REPOSITORY TAG",
|
|
402
|
+
"docker.io/docker/sandbox-templates shell",
|
|
403
|
+
`docker.io/library/pi-sbx-lite 1a2b3c4d`,
|
|
404
|
+
].join("\n");
|
|
405
|
+
const flatLayout = ["docker.io/docker/sandbox-templates:shell", fullRef].join("\n");
|
|
406
|
+
check("parses the table layout", templateListHas(tableLayout, tag) === true);
|
|
407
|
+
check("parses the flat layout", templateListHas(flatLayout, tag) === true);
|
|
408
|
+
check("does not match a different version", templateListHas(tableLayout, "pi-sbx-lite:deadbeef") === false);
|
|
409
|
+
check("does not match an unrelated template", templateListHas(tableLayout, "pi:v1") === false);
|
|
410
|
+
// A stock row must never be mistaken for ours (the bug the basename match exists to avoid).
|
|
411
|
+
check(
|
|
412
|
+
"never matches the stock base row by accident",
|
|
413
|
+
templateListHas("docker.io/docker/sandbox-templates shell", "pi-sbx-lite:1a2b3c4d") === false,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// The handshake must never be the reason a session fails.
|
|
417
|
+
process.env.SBX_BACKEND = "sbx";
|
|
418
|
+
delete process.env.SBX_DOCKER_CONTAINER;
|
|
419
|
+
const stateDir = path.join(os.tmpdir(), `pi-sbx-lite-test-${process.pid}`);
|
|
420
|
+
fs.mkdirSync(path.join(stateDir, "pi-sbx-lite"), { recursive: true });
|
|
421
|
+
process.env.XDG_CACHE_HOME = stateDir;
|
|
422
|
+
|
|
423
|
+
delete process.env.DOCKER_SANDBOX_TEMPLATE;
|
|
424
|
+
try {
|
|
425
|
+
await resolveTransport();
|
|
426
|
+
} catch {
|
|
427
|
+
/* sbx absent, expected */
|
|
428
|
+
}
|
|
429
|
+
check("no recorded template => no template is forced", process.env.DOCKER_SANDBOX_TEMPLATE === undefined, String(process.env.DOCKER_SANDBOX_TEMPLATE));
|
|
430
|
+
|
|
431
|
+
// A recorded ref that cannot be verified (no sbx here) must be ignored, not trusted.
|
|
432
|
+
fs.writeFileSync(path.join(stateDir, "pi-sbx-lite", "template-ref"), `${fullRef}\n`);
|
|
433
|
+
try {
|
|
434
|
+
await resolveTransport();
|
|
435
|
+
} catch {
|
|
436
|
+
/* expected */
|
|
437
|
+
}
|
|
438
|
+
check(
|
|
439
|
+
"an unverifiable recorded template degrades to the stock base",
|
|
440
|
+
process.env.DOCKER_SANDBOX_TEMPLATE === undefined,
|
|
441
|
+
String(process.env.DOCKER_SANDBOX_TEMPLATE),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// An explicit setting is never overridden by the recorded one.
|
|
445
|
+
process.env.DOCKER_SANDBOX_TEMPLATE = "my-explicit:v9";
|
|
446
|
+
try {
|
|
447
|
+
await resolveTransport();
|
|
448
|
+
} catch {
|
|
449
|
+
/* expected */
|
|
450
|
+
}
|
|
451
|
+
check("an explicit DOCKER_SANDBOX_TEMPLATE wins", process.env.DOCKER_SANDBOX_TEMPLATE === "my-explicit:v9", String(process.env.DOCKER_SANDBOX_TEMPLATE));
|
|
452
|
+
delete process.env.DOCKER_SANDBOX_TEMPLATE;
|
|
453
|
+
delete process.env.XDG_CACHE_HOME;
|
|
454
|
+
|
|
455
|
+
/* --- tooling reachable inside the sandbox ------------------------- */
|
|
456
|
+
console.log("\ninfo");
|
|
457
|
+
const dockerInside = await byName.bash.execute("t11", { command: "command -v docker || echo none" }, undefined, undefined, undefined);
|
|
458
|
+
console.log(` docker inside the test container: ${dockerInside.content[0].text.trim()}`);
|
|
459
|
+
console.log(` (an sbx sandbox always has its own docker daemon — that is why routing bash`);
|
|
460
|
+
console.log(` makes the docker_* deploy tools redundant)`);
|
|
461
|
+
} finally {
|
|
462
|
+
try {
|
|
463
|
+
docker(["rm", "-f", CONTAINER], { stdio: "ignore" });
|
|
464
|
+
} catch {}
|
|
465
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
console.log(`\n${failures === 0 ? "ALL PASS" : `${failures} FAILURE(S)`}\n`);
|
|
469
|
+
process.exit(failures === 0 ? 0 : 1);
|