@omg-dev/sandbox 0.4.27 → 0.4.29
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/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as SandboxClient, a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, g as SandboxApiError, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run, v as assertTemplateId } from "./templates-
|
|
1
|
+
import { _ as SandboxClient, a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, g as SandboxApiError, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run, v as assertTemplateId } from "./templates-C3mBSGdT.mjs";
|
|
2
2
|
export { SandboxApiError, SandboxClient, applyTemplate, apt, assertTemplateId, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
4
|
//#region src/client.ts
|
|
@@ -228,6 +228,67 @@ function compileStartScript(start) {
|
|
|
228
228
|
async function assertExec(label, result) {
|
|
229
229
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
230
230
|
}
|
|
231
|
+
async function runTemplateStep(sandbox, label, script, timeoutMs) {
|
|
232
|
+
const workDir = `/tmp/omg-template-step-${randomUUID()}`;
|
|
233
|
+
const pidPath = `${workDir}/pid`;
|
|
234
|
+
const statusPath = `${workDir}/status`;
|
|
235
|
+
const stdoutPath = `${workDir}/stdout`;
|
|
236
|
+
const stderrPath = `${workDir}/stderr`;
|
|
237
|
+
const wrapper = [
|
|
238
|
+
"set -u",
|
|
239
|
+
`mkdir -p ${quote(workDir)}`,
|
|
240
|
+
`printf '%s\\n' \"$$\" > ${quote(pidPath)}`,
|
|
241
|
+
`set +e; /bin/bash -lc ${quote(script)} > ${quote(stdoutPath)} 2> ${quote(stderrPath)}`,
|
|
242
|
+
"code=$?",
|
|
243
|
+
`printf '%s\\n' \"$code\" > ${quote(`${statusPath}.tmp`)}`,
|
|
244
|
+
`mv ${quote(`${statusPath}.tmp`)} ${quote(statusPath)}`,
|
|
245
|
+
"exit \"$code\""
|
|
246
|
+
].join("\n");
|
|
247
|
+
const prepared = await sandbox.shell(`mkdir -p ${quote(workDir)}`, { timeoutMs: 1e4 });
|
|
248
|
+
await assertExec(`${label} workspace preparation`, prepared);
|
|
249
|
+
const deadline = Date.now() + timeoutMs;
|
|
250
|
+
const launchDeadline = Math.min(deadline, Date.now() + 1e4);
|
|
251
|
+
try {
|
|
252
|
+
if (!(await sandbox.shell(wrapper, { detached: true })).commandId) throw new Error(`${label} did not start as a detached guest command`);
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
const probe = await sandbox.shell([
|
|
255
|
+
`if test -f ${quote(statusPath)}; then`,
|
|
256
|
+
" printf 'done\\n'",
|
|
257
|
+
` cat ${quote(statusPath)}`,
|
|
258
|
+
`elif test -f ${quote(pidPath)} && kill -0 \"$(cat ${quote(pidPath)})\" 2>/dev/null; then`,
|
|
259
|
+
" printf 'running\\n'",
|
|
260
|
+
`elif test ! -f ${quote(pidPath)}; then`,
|
|
261
|
+
" printf 'starting\\n'",
|
|
262
|
+
"else",
|
|
263
|
+
" printf 'lost\\n'",
|
|
264
|
+
"fi"
|
|
265
|
+
].join("\n"), { timeoutMs: 1e4 });
|
|
266
|
+
await assertExec(`${label} status probe`, probe);
|
|
267
|
+
const [state, rawExitCode] = probe.stdout.trim().split(/\s+/, 2);
|
|
268
|
+
if (state === "done") {
|
|
269
|
+
const exitCode = Number(rawExitCode);
|
|
270
|
+
if (!Number.isInteger(exitCode)) throw new Error(`${label} wrote an invalid exit status: ${JSON.stringify(rawExitCode)}`);
|
|
271
|
+
const stdout = await sandbox.shell(`cat ${quote(stdoutPath)} 2>/dev/null || true`, { timeoutMs: 1e4 });
|
|
272
|
+
const stderr = await sandbox.shell(`cat ${quote(stderrPath)} 2>/dev/null || true`, { timeoutMs: 1e4 });
|
|
273
|
+
await assertExec(`${label} stdout read`, stdout);
|
|
274
|
+
await assertExec(`${label} stderr read`, stderr);
|
|
275
|
+
return {
|
|
276
|
+
exitCode,
|
|
277
|
+
stdout: stdout.stdout,
|
|
278
|
+
stderr: stderr.stdout
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (state === "lost") throw new Error(`${label} guest process exited without a completion record`);
|
|
282
|
+
if (state === "starting" && Date.now() >= launchDeadline) throw new Error(`${label} guest process never initialized`);
|
|
283
|
+
if (state !== "running" && state !== "starting") throw new Error(`${label} returned an invalid guest state: ${JSON.stringify(state)}`);
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
285
|
+
}
|
|
286
|
+
await sandbox.shell(`test ! -f ${quote(pidPath)} || kill \"$(cat ${quote(pidPath)})\" 2>/dev/null || true`, { timeoutMs: 1e4 }).catch(() => {});
|
|
287
|
+
throw new Error(`${label} exceeded its ${timeoutMs}ms guest execution deadline`);
|
|
288
|
+
} finally {
|
|
289
|
+
await sandbox.shell(`rm -rf ${quote(workDir)}`, { timeoutMs: 1e4 }).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
231
292
|
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
232
293
|
async function walkSourceDir(dir) {
|
|
233
294
|
const out = [];
|
|
@@ -272,7 +333,8 @@ async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
|
272
333
|
continue;
|
|
273
334
|
}
|
|
274
335
|
const compiled = compileStep(step);
|
|
275
|
-
|
|
336
|
+
const label = `template step ${index + 1}`;
|
|
337
|
+
await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 6e4));
|
|
276
338
|
}
|
|
277
339
|
if (definition.start) await sandbox.writeFiles([{
|
|
278
340
|
path: "/home/user/.omg/template/bootstrap.sh",
|
package/dist/templates.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run } from "./templates-
|
|
1
|
+
import { a as compileStartScript, c as download, d as templateContentVersionRef, f as templateDefinitionHash, h as waitForSnapshotUpload, i as check, l as files, m as templateVersionRef, n as apt, o as compileStep, p as templateRuntimeContract, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as run } from "./templates-C3mBSGdT.mjs";
|
|
2
2
|
export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
package/package.json
CHANGED
package/src/templates.test.ts
CHANGED
|
@@ -87,28 +87,80 @@ describe("sandbox templates", () => {
|
|
|
87
87
|
await expect(applyTemplate(sandbox, definition)).rejects.toThrow();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test("applyTemplate runs install commands out of band from the CDN request window", async () => {
|
|
91
|
+
const calls: Array<{ script: string; detached?: boolean }> = [];
|
|
92
|
+
const writes: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>> = [];
|
|
93
|
+
const sandbox: Sandbox = {
|
|
94
|
+
id: "fake",
|
|
95
|
+
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
96
|
+
shell: async (script, options = {}) => {
|
|
97
|
+
calls.push({ script, detached: options.detached });
|
|
98
|
+
if (options.detached) return { stdout: "", stderr: "", exitCode: 0, commandId: "cmd-1" };
|
|
99
|
+
if (script.includes("printf 'done")) return { stdout: "done\n0\n", stderr: "", exitCode: 0 };
|
|
100
|
+
if (script.includes("/stdout")) return { stdout: "installed\n", stderr: "", exitCode: 0 };
|
|
101
|
+
if (script.includes("/stderr")) return { stdout: "", stderr: "", exitCode: 0 };
|
|
102
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
103
|
+
},
|
|
104
|
+
writeFiles: async (batch) => { writes.push(batch); },
|
|
105
|
+
snapshot: async () => ({ id: "snap", sizeBytes: 0, createdAt: new Date(0).toISOString() }),
|
|
106
|
+
stop: async () => {},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
await applyTemplate(sandbox, defineTemplate({
|
|
110
|
+
id: "async-install",
|
|
111
|
+
version: "1",
|
|
112
|
+
title: "async install",
|
|
113
|
+
install: [run({ command: "sleep 120 && echo installed" })],
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const detached = calls.find((call) => call.detached);
|
|
117
|
+
expect(detached).toBeDefined();
|
|
118
|
+
expect(detached?.script).toContain("sleep 120 && echo installed");
|
|
119
|
+
expect(calls.some((call) => call.script.includes("printf 'done"))).toBe(true);
|
|
120
|
+
expect(calls.some((call) => call.script.includes("printf 'starting"))).toBe(true);
|
|
121
|
+
expect(writes.some((batch) => batch.some((file) => file.path.endsWith("definition.json")))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("applyTemplate preserves a detached install's failing exit status and stderr", async () => {
|
|
125
|
+
const sandbox = fakeSandbox();
|
|
126
|
+
sandbox.shell = async (script, options = {}) => {
|
|
127
|
+
if (options.detached) return { stdout: "", stderr: "", exitCode: 0, commandId: "cmd-2" };
|
|
128
|
+
if (script.includes("printf 'done")) return { stdout: "done\n42\n", stderr: "", exitCode: 0 };
|
|
129
|
+
if (script.includes("/stdout")) return { stdout: "", stderr: "", exitCode: 0 };
|
|
130
|
+
if (script.includes("/stderr")) return { stdout: "package exploded\n", stderr: "", exitCode: 0 };
|
|
131
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await expect(applyTemplate(sandbox, defineTemplate({
|
|
135
|
+
id: "async-failure",
|
|
136
|
+
version: "1",
|
|
137
|
+
title: "async failure",
|
|
138
|
+
install: [run({ command: "exit 42" })],
|
|
139
|
+
}))).rejects.toThrow("package exploded");
|
|
140
|
+
});
|
|
141
|
+
|
|
90
142
|
test("LFG owns every runtime dependency and zero-config transport default", () => {
|
|
91
143
|
const lfg = agentTemplates.lfg;
|
|
92
|
-
expect(templateVersionRef(lfg)).toBe("agent-lfg-
|
|
144
|
+
expect(templateVersionRef(lfg)).toBe("agent-lfg-v17");
|
|
93
145
|
expect(lfg.install).toContainEqual(apt.packages(["tmux"]));
|
|
94
146
|
expect(lfg.checks).toContainEqual(check.command("tmux"));
|
|
95
147
|
expect(lfg.start?.env?.LIVE_TRANSPORT).toBe("ws");
|
|
96
148
|
expect(lfg.start?.readiness?.port).toBe(8766);
|
|
97
149
|
expect(templateRuntimeContract(lfg, templateVersionRef(lfg))).toEqual({
|
|
98
|
-
immutableRef: "agent-lfg-
|
|
150
|
+
immutableRef: "agent-lfg-v17",
|
|
99
151
|
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
100
152
|
readinessPort: 8766,
|
|
101
153
|
readinessPath: "/",
|
|
102
|
-
ports: [8766],
|
|
154
|
+
ports: [8766, 5173],
|
|
103
155
|
});
|
|
104
156
|
});
|
|
105
157
|
|
|
106
|
-
test("LFG bakes the react-ts scaffold into /home/user/
|
|
158
|
+
test("LFG bakes the react-ts scaffold into /home/user/project at bake time", () => {
|
|
107
159
|
const lfg = agentTemplates.lfg;
|
|
108
160
|
const filesSteps = lfg.install.filter((step) => step.kind === "files");
|
|
109
161
|
expect(filesSteps).toHaveLength(1);
|
|
110
162
|
const scaffold = filesSteps[0] as Extract<(typeof filesSteps)[number], { kind: "files" }>;
|
|
111
|
-
expect(scaffold.destination).toBe("/home/user/
|
|
163
|
+
expect(scaffold.destination).toBe("/home/user/project");
|
|
112
164
|
expect(scaffold.sourceDir.endsWith(join("templates", "react-ts"))).toBe(true);
|
|
113
165
|
// recipe.sh (shadcn init) must run against the baked scaffold, not just
|
|
114
166
|
// land on disk unused — otherwise Computers still improvise shadcn init
|
|
@@ -117,6 +169,6 @@ describe("sandbox templates", () => {
|
|
|
117
169
|
.filter((step): step is Extract<typeof step, { kind: "run" }> => step.kind === "run")
|
|
118
170
|
.map((step) => step.command);
|
|
119
171
|
expect(commands.some((c) => c.includes("recipe.sh"))).toBe(true);
|
|
120
|
-
expect(lfg.metadata?.lfgRelease).toBe("v0.1.
|
|
172
|
+
expect(lfg.metadata?.lfgRelease).toBe("v0.1.40");
|
|
121
173
|
});
|
|
122
174
|
});
|
package/src/templates.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
4
|
import type { Sandbox, SandboxClient, Snapshot } from "./client.js";
|
|
@@ -203,6 +203,77 @@ async function assertExec(label: string, result: { exitCode: number; stdout: str
|
|
|
203
203
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// Template installs routinely outlive Cloudflare's request window (apt and
|
|
207
|
+
// package-manager steps can take several minutes). A blocking /exec request
|
|
208
|
+
// therefore makes a healthy guest look failed with a 524. Launch the command
|
|
209
|
+
// inside the guest and make its on-disk completion record the authority; every
|
|
210
|
+
// control-plane request below is short and bounded, while exit status and logs
|
|
211
|
+
// still fail loud exactly once.
|
|
212
|
+
async function runTemplateStep(
|
|
213
|
+
sandbox: Sandbox,
|
|
214
|
+
label: string,
|
|
215
|
+
script: string,
|
|
216
|
+
timeoutMs: number,
|
|
217
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
218
|
+
const workDir = `/tmp/omg-template-step-${randomUUID()}`;
|
|
219
|
+
const pidPath = `${workDir}/pid`;
|
|
220
|
+
const statusPath = `${workDir}/status`;
|
|
221
|
+
const stdoutPath = `${workDir}/stdout`;
|
|
222
|
+
const stderrPath = `${workDir}/stderr`;
|
|
223
|
+
const wrapper = [
|
|
224
|
+
"set -u",
|
|
225
|
+
`mkdir -p ${quote(workDir)}`,
|
|
226
|
+
`printf '%s\\n' \"$$\" > ${quote(pidPath)}`,
|
|
227
|
+
`set +e; /bin/bash -lc ${quote(script)} > ${quote(stdoutPath)} 2> ${quote(stderrPath)}`,
|
|
228
|
+
"code=$?",
|
|
229
|
+
`printf '%s\\n' \"$code\" > ${quote(`${statusPath}.tmp`)}`,
|
|
230
|
+
`mv ${quote(`${statusPath}.tmp`)} ${quote(statusPath)}`,
|
|
231
|
+
"exit \"$code\"",
|
|
232
|
+
].join("\n");
|
|
233
|
+
|
|
234
|
+
const prepared = await sandbox.shell(`mkdir -p ${quote(workDir)}`, { timeoutMs: 10_000 });
|
|
235
|
+
await assertExec(`${label} workspace preparation`, prepared);
|
|
236
|
+
const deadline = Date.now() + timeoutMs;
|
|
237
|
+
const launchDeadline = Math.min(deadline, Date.now() + 10_000);
|
|
238
|
+
try {
|
|
239
|
+
const started = await sandbox.shell(wrapper, { detached: true });
|
|
240
|
+
if (!started.commandId) throw new Error(`${label} did not start as a detached guest command`);
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const probe = await sandbox.shell([
|
|
243
|
+
`if test -f ${quote(statusPath)}; then`,
|
|
244
|
+
" printf 'done\\n'",
|
|
245
|
+
` cat ${quote(statusPath)}`,
|
|
246
|
+
`elif test -f ${quote(pidPath)} && kill -0 \"$(cat ${quote(pidPath)})\" 2>/dev/null; then`,
|
|
247
|
+
" printf 'running\\n'",
|
|
248
|
+
`elif test ! -f ${quote(pidPath)}; then`,
|
|
249
|
+
" printf 'starting\\n'",
|
|
250
|
+
"else",
|
|
251
|
+
" printf 'lost\\n'",
|
|
252
|
+
"fi",
|
|
253
|
+
].join("\n"), { timeoutMs: 10_000 });
|
|
254
|
+
await assertExec(`${label} status probe`, probe);
|
|
255
|
+
const [state, rawExitCode] = probe.stdout.trim().split(/\s+/, 2);
|
|
256
|
+
if (state === "done") {
|
|
257
|
+
const exitCode = Number(rawExitCode);
|
|
258
|
+
if (!Number.isInteger(exitCode)) throw new Error(`${label} wrote an invalid exit status: ${JSON.stringify(rawExitCode)}`);
|
|
259
|
+
const stdout = await sandbox.shell(`cat ${quote(stdoutPath)} 2>/dev/null || true`, { timeoutMs: 10_000 });
|
|
260
|
+
const stderr = await sandbox.shell(`cat ${quote(stderrPath)} 2>/dev/null || true`, { timeoutMs: 10_000 });
|
|
261
|
+
await assertExec(`${label} stdout read`, stdout);
|
|
262
|
+
await assertExec(`${label} stderr read`, stderr);
|
|
263
|
+
return { exitCode, stdout: stdout.stdout, stderr: stderr.stdout };
|
|
264
|
+
}
|
|
265
|
+
if (state === "lost") throw new Error(`${label} guest process exited without a completion record`);
|
|
266
|
+
if (state === "starting" && Date.now() >= launchDeadline) throw new Error(`${label} guest process never initialized`);
|
|
267
|
+
if (state !== "running" && state !== "starting") throw new Error(`${label} returned an invalid guest state: ${JSON.stringify(state)}`);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
269
|
+
}
|
|
270
|
+
await sandbox.shell(`test ! -f ${quote(pidPath)} || kill \"$(cat ${quote(pidPath)})\" 2>/dev/null || true`, { timeoutMs: 10_000 }).catch(() => {});
|
|
271
|
+
throw new Error(`${label} exceeded its ${timeoutMs}ms guest execution deadline`);
|
|
272
|
+
} finally {
|
|
273
|
+
await sandbox.shell(`rm -rf ${quote(workDir)}`, { timeoutMs: 10_000 }).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
206
277
|
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
207
278
|
|
|
208
279
|
async function walkSourceDir(dir: string): Promise<Array<{ relPath: string; buf: Buffer }>> {
|
|
@@ -251,7 +322,8 @@ export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplat
|
|
|
251
322
|
continue;
|
|
252
323
|
}
|
|
253
324
|
const compiled = compileStep(step);
|
|
254
|
-
|
|
325
|
+
const label = `template step ${index + 1}`;
|
|
326
|
+
await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 60_000));
|
|
255
327
|
}
|
|
256
328
|
if (definition.start) {
|
|
257
329
|
await sandbox.writeFiles([{ path: "/home/user/.omg/template/bootstrap.sh", content: compileStartScript(definition.start), mode: 0o755 }]);
|