@omg-dev/sandbox 0.4.25 → 0.4.27
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 +2 -2
- package/dist/{templates-CdQWcmOC.mjs → templates-D2llpqYs.mjs} +119 -13
- package/dist/templates.mjs +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/mini-template/.gitkeep +0 -0
- package/src/__fixtures__/mini-template/nested/hello.txt +1 -0
- package/src/__fixtures__/mini-template/package.json +4 -0
- package/src/client.test.ts +127 -20
- package/src/client.ts +19 -1
- package/src/templates.test.ts +84 -3
- package/src/templates.ts +166 -16
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as compileStartScript, c as download, d as
|
|
2
|
-
export { SandboxClient, applyTemplate, apt, assertTemplateId, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, run, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
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-D2llpqYs.mjs";
|
|
2
|
+
export { SandboxApiError, SandboxClient, applyTemplate, apt, assertTemplateId, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
2
4
|
//#region src/client.ts
|
|
5
|
+
/** Error thrown by SandboxClient.request carrying the HTTP status, so callers
|
|
6
|
+
* can branch on well-defined statuses (e.g. 404 template probe) instead of
|
|
7
|
+
* string-matching error messages. */
|
|
8
|
+
var SandboxApiError = class extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "SandboxApiError";
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
3
16
|
const TEMPLATE_ID = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
|
4
17
|
function assertTemplateId(id) {
|
|
5
18
|
const normalized = id.trim().toLowerCase();
|
|
@@ -38,7 +51,7 @@ var SandboxClient = class {
|
|
|
38
51
|
} catch {}
|
|
39
52
|
if (!response.ok) {
|
|
40
53
|
const detail = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : text || `HTTP ${response.status}`;
|
|
41
|
-
throw new
|
|
54
|
+
throw new SandboxApiError(`sandbox API ${method} ${path}: ${response.status} ${detail}`, response.status);
|
|
42
55
|
}
|
|
43
56
|
return parsed;
|
|
44
57
|
}
|
|
@@ -49,6 +62,9 @@ var SandboxClient = class {
|
|
|
49
62
|
async getSnapshot(id) {
|
|
50
63
|
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
51
64
|
}
|
|
65
|
+
async deleteSnapshot(id) {
|
|
66
|
+
await this.request("DELETE", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
67
|
+
}
|
|
52
68
|
async publishTemplate(templateId, snapshotId, runtime) {
|
|
53
69
|
const id = assertTemplateId(templateId);
|
|
54
70
|
await this.request("POST", `/v1/templates/${id}/latest`, {
|
|
@@ -107,6 +123,14 @@ function run(options) {
|
|
|
107
123
|
...options
|
|
108
124
|
};
|
|
109
125
|
}
|
|
126
|
+
const files = { copy(options) {
|
|
127
|
+
if (!options.sourceDir.trim()) throw new Error("files.copy requires a sourceDir");
|
|
128
|
+
if (!options.destination.trim()) throw new Error("files.copy requires a destination");
|
|
129
|
+
return {
|
|
130
|
+
kind: "files",
|
|
131
|
+
...options
|
|
132
|
+
};
|
|
133
|
+
} };
|
|
110
134
|
const check = {
|
|
111
135
|
command(command, options = {}) {
|
|
112
136
|
return {
|
|
@@ -129,11 +153,18 @@ function defineTemplate(definition) {
|
|
|
129
153
|
for (const port of definition.ports ?? []) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid template port ${port}`);
|
|
130
154
|
return Object.freeze(definition);
|
|
131
155
|
}
|
|
132
|
-
function
|
|
133
|
-
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
156
|
+
function refWithSuffix(id, suffix) {
|
|
134
157
|
const maxBase = 40 - suffix.length;
|
|
135
158
|
if (maxBase < 2) throw new Error("template id and version are too long for registry");
|
|
136
|
-
return assertTemplateId(`${
|
|
159
|
+
return assertTemplateId(`${id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
|
|
160
|
+
}
|
|
161
|
+
function templateVersionRef(definition) {
|
|
162
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`);
|
|
163
|
+
}
|
|
164
|
+
function templateContentVersionRef(definition, contentKey) {
|
|
165
|
+
if (!contentKey.trim()) throw new Error("template content key (base rootfs sha) is required");
|
|
166
|
+
const hash = createHash("sha256").update(`${templateDefinitionHash(definition)}\n${contentKey.trim()}`).digest("hex").slice(0, 8);
|
|
167
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}-${hash}`);
|
|
137
168
|
}
|
|
138
169
|
function templateDefinitionHash(definition) {
|
|
139
170
|
return createHash("sha256").update(JSON.stringify(definition)).digest("hex");
|
|
@@ -150,10 +181,19 @@ function templateRuntimeContract(definition, immutableRef) {
|
|
|
150
181
|
function quote(value) {
|
|
151
182
|
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
|
152
183
|
}
|
|
184
|
+
const PROXY_ENV_VARS = [
|
|
185
|
+
"OMG_AI_URL",
|
|
186
|
+
"ANTHROPIC_BASE_URL",
|
|
187
|
+
"ANTHROPIC_API_KEY",
|
|
188
|
+
"OPENAI_BASE_URL",
|
|
189
|
+
"OPENAI_API_KEY",
|
|
190
|
+
"OMG_MEDIA_URL"
|
|
191
|
+
];
|
|
153
192
|
function commandForUser(command, user) {
|
|
154
193
|
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
155
194
|
const home = user === "root" ? "/root" : `/home/${user}`;
|
|
156
|
-
|
|
195
|
+
const proxyEnv = PROXY_ENV_VARS.map((name) => `${name}="\${${name}:-}"`).join(" ");
|
|
196
|
+
return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} ${proxyEnv} /bin/bash -c ${quote(command)}`;
|
|
157
197
|
}
|
|
158
198
|
function shellEnvAssignments(env) {
|
|
159
199
|
return Object.entries(env).map(([key, value]) => {
|
|
@@ -162,6 +202,7 @@ function shellEnvAssignments(env) {
|
|
|
162
202
|
});
|
|
163
203
|
}
|
|
164
204
|
function compileStep(step) {
|
|
205
|
+
if (step.kind === "files") throw new Error("files steps transfer local content and are applied via applyTemplate(), not compileStep()");
|
|
165
206
|
if (step.kind === "apt") return { script: `set -euo pipefail\napt-get update -qq\nDEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends ${step.packages.map(quote).join(" ")}\nrm -rf /var/lib/apt/lists/*` };
|
|
166
207
|
if (step.kind === "archive") {
|
|
167
208
|
const strip = step.stripComponents ?? 1;
|
|
@@ -187,9 +228,49 @@ function compileStartScript(start) {
|
|
|
187
228
|
async function assertExec(label, result) {
|
|
188
229
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
189
230
|
}
|
|
231
|
+
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
232
|
+
async function walkSourceDir(dir) {
|
|
233
|
+
const out = [];
|
|
234
|
+
async function visit(d) {
|
|
235
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
const full = join(d, entry.name);
|
|
238
|
+
if (entry.isDirectory()) {
|
|
239
|
+
await visit(full);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
|
|
243
|
+
out.push({
|
|
244
|
+
relPath: relative(dir, full),
|
|
245
|
+
buf: await readFile(full)
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await visit(dir);
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
async function applyFilesStep(sandbox, step, onLog) {
|
|
253
|
+
const walked = await walkSourceDir(step.sourceDir);
|
|
254
|
+
if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
|
|
255
|
+
const destination = step.destination.replace(/\/$/, "");
|
|
256
|
+
const batchSize = 50;
|
|
257
|
+
for (let i = 0; i < walked.length; i += batchSize) {
|
|
258
|
+
const batch = walked.slice(i, i + batchSize).map((f) => ({
|
|
259
|
+
path: `${destination}/${f.relPath}`,
|
|
260
|
+
content: f.buf,
|
|
261
|
+
mode: 420
|
|
262
|
+
}));
|
|
263
|
+
await sandbox.writeFiles(batch);
|
|
264
|
+
}
|
|
265
|
+
onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
|
|
266
|
+
}
|
|
190
267
|
async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
191
268
|
for (const [index, step] of definition.install.entries()) {
|
|
192
269
|
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
270
|
+
if (step.kind === "files") {
|
|
271
|
+
await applyFilesStep(sandbox, step, onLog);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
193
274
|
const compiled = compileStep(step);
|
|
194
275
|
await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 6e4 }));
|
|
195
276
|
}
|
|
@@ -215,14 +296,14 @@ async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
|
215
296
|
async function waitForSnapshotUpload(client, snapshot, timeoutMs = 10 * 6e4) {
|
|
216
297
|
const deadline = Date.now() + timeoutMs;
|
|
217
298
|
while (Date.now() < deadline) {
|
|
218
|
-
|
|
299
|
+
const uploaded = await client.getSnapshot(snapshot.id);
|
|
300
|
+
if (uploaded.uploadedToTigris) return uploaded;
|
|
219
301
|
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
220
302
|
}
|
|
221
303
|
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
222
304
|
}
|
|
223
305
|
async function bakeTemplate(client, definition, options = {}) {
|
|
224
306
|
const log = options.onLog ?? (() => {});
|
|
225
|
-
const versionRef = templateVersionRef(definition);
|
|
226
307
|
const sandbox = await client.create({
|
|
227
308
|
ports: [...definition.ports ?? []],
|
|
228
309
|
skipAppProcesses: true,
|
|
@@ -233,19 +314,44 @@ async function bakeTemplate(client, definition, options = {}) {
|
|
|
233
314
|
await applyTemplate(sandbox, definition, log);
|
|
234
315
|
const snapshot = await sandbox.snapshot();
|
|
235
316
|
snapshotted = true;
|
|
236
|
-
await waitForSnapshotUpload(client, snapshot);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
317
|
+
const rootfsSha = (await waitForSnapshotUpload(client, snapshot)).rootfsSha?.trim();
|
|
318
|
+
if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha — cannot derive an immutable template version identity`);
|
|
319
|
+
const versionRef = templateContentVersionRef(definition, rootfsSha);
|
|
320
|
+
const publishClient = options.systemClient ?? client;
|
|
321
|
+
const runtime = templateRuntimeContract(definition, publishClient.ownerId ? void 0 : versionRef);
|
|
322
|
+
const existing = await resolveTemplateVersion(publishClient, versionRef);
|
|
323
|
+
if (existing) {
|
|
324
|
+
log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
|
|
325
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
|
|
326
|
+
await client.deleteSnapshot(snapshot.id).catch(() => {});
|
|
327
|
+
return {
|
|
328
|
+
templateId: definition.id,
|
|
329
|
+
versionRef,
|
|
330
|
+
definitionHash: templateDefinitionHash(definition),
|
|
331
|
+
snapshotId: existing.snapshotId,
|
|
332
|
+
reusedExisting: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
|
|
336
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
|
|
240
337
|
return {
|
|
241
338
|
templateId: definition.id,
|
|
242
339
|
versionRef,
|
|
243
340
|
definitionHash: templateDefinitionHash(definition),
|
|
244
|
-
snapshotId: snapshot.id
|
|
341
|
+
snapshotId: snapshot.id,
|
|
342
|
+
reusedExisting: false
|
|
245
343
|
};
|
|
246
344
|
} finally {
|
|
247
345
|
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
248
346
|
}
|
|
249
347
|
}
|
|
348
|
+
async function resolveTemplateVersion(client, versionRef) {
|
|
349
|
+
try {
|
|
350
|
+
return await client.resolveTemplate(versionRef);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (err instanceof SandboxApiError && err.status === 404) return void 0;
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
250
356
|
//#endregion
|
|
251
|
-
export { compileStartScript as a, download as c,
|
|
357
|
+
export { SandboxClient as _, compileStartScript as a, download as c, templateContentVersionRef as d, templateDefinitionHash as f, SandboxApiError as g, waitForSnapshotUpload as h, check as i, files as l, templateVersionRef as m, apt as n, compileStep as o, templateRuntimeContract as p, bakeTemplate as r, defineTemplate as s, applyTemplate as t, run as u, assertTemplateId as v };
|
package/dist/templates.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as compileStartScript, c as download, d as
|
|
2
|
-
export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, run, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
|
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-D2llpqYs.mjs";
|
|
2
|
+
export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
|
package/package.json
CHANGED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hello from the fixture
|
package/src/client.test.ts
CHANGED
|
@@ -1,39 +1,104 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { SandboxClient } from "./client";
|
|
3
|
-
import { bakeTemplate, defineTemplate } from "./templates";
|
|
3
|
+
import { bakeTemplate, defineTemplate, templateContentVersionRef, templateVersionRef } from "./templates";
|
|
4
|
+
|
|
5
|
+
interface RecordedCall { method: string; path: string; body?: unknown; owner?: string | null }
|
|
6
|
+
|
|
7
|
+
// Fake infra API for bake flows. Registry versions are immutable content-
|
|
8
|
+
// addressed refs: GET on an unknown ref 404s (fresh publish), GET on a known
|
|
9
|
+
// ref returns its snapshot (reuse path). POSTing an existing immutable ref
|
|
10
|
+
// with a different snapshot would 409 in production — tests assert bakeTemplate
|
|
11
|
+
// never attempts it.
|
|
12
|
+
function fakeInfra(options: { existingVersions?: Record<string, string>; snapshotRootfsSha?: string | null } = {}) {
|
|
13
|
+
const calls: RecordedCall[] = [];
|
|
14
|
+
const existing = options.existingVersions ?? {};
|
|
15
|
+
const fakeFetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
16
|
+
const url = new URL(String(input));
|
|
17
|
+
const method = init?.method ?? "GET";
|
|
18
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
19
|
+
calls.push({ method, path: url.pathname, body, owner: new Headers(init?.headers).get("x-on-behalf-of") });
|
|
20
|
+
const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
|
|
21
|
+
if (url.pathname === "/v1/sandboxes" && method === "POST") return json({ id: "sb-1" });
|
|
22
|
+
if (url.pathname.endsWith("/files")) return json({ ok: true });
|
|
23
|
+
if (url.pathname.endsWith("/snapshot")) return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z" });
|
|
24
|
+
if (url.pathname === "/v1/snapshots/snap-1" && method === "GET") {
|
|
25
|
+
const snap: Record<string, unknown> = { id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z", uploadedToTigris: true };
|
|
26
|
+
if (options.snapshotRootfsSha !== null) snap.rootfsSha = options.snapshotRootfsSha ?? "rootfs-sha-1";
|
|
27
|
+
return json(snap);
|
|
28
|
+
}
|
|
29
|
+
if (url.pathname === "/v1/snapshots/snap-1" && method === "DELETE") return new Response(null, { status: 204 });
|
|
30
|
+
const templateMatch = url.pathname.match(/^\/v1\/templates\/([^/]+)\/latest$/);
|
|
31
|
+
if (templateMatch && method === "GET") {
|
|
32
|
+
const snapshotId = existing[templateMatch[1]!];
|
|
33
|
+
if (!snapshotId) return json({ error: "template not found" }, 404);
|
|
34
|
+
return json({ templateId: templateMatch[1], snapshotId });
|
|
35
|
+
}
|
|
36
|
+
if (templateMatch && method === "POST") return json({ ok: true });
|
|
37
|
+
throw new Error(`unexpected ${method} ${url.pathname}`);
|
|
38
|
+
}) as typeof fetch;
|
|
39
|
+
return { calls, fakeFetch };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("template version identity", () => {
|
|
43
|
+
const definition = defineTemplate({
|
|
44
|
+
id: "my-agent",
|
|
45
|
+
version: "3",
|
|
46
|
+
title: "Mine",
|
|
47
|
+
install: [],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("content ref covers definition AND base rootfs", () => {
|
|
51
|
+
const ref = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
52
|
+
expect(ref).toMatch(/^my-agent-v3-[0-9a-f]{8}$/);
|
|
53
|
+
// Deterministic for identical inputs — identical content resolves to the
|
|
54
|
+
// same immutable version instead of minting duplicates.
|
|
55
|
+
expect(templateContentVersionRef(definition, "rootfs-sha-1")).toBe(ref);
|
|
56
|
+
// A rootfs change (same catalog definition) MUST mint a new identity —
|
|
57
|
+
// this is the exact collision that 409ed the Jul-18 rebake, where the
|
|
58
|
+
// static <id>-v<version> ref could not express a base-image change.
|
|
59
|
+
expect(templateContentVersionRef(definition, "rootfs-sha-2")).not.toBe(ref);
|
|
60
|
+
// A definition change also mints a new identity.
|
|
61
|
+
const bumped = defineTemplate({ ...definition, title: "Mine v2" });
|
|
62
|
+
expect(templateContentVersionRef(bumped, "rootfs-sha-1")).not.toBe(ref);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty content key fails loud instead of falling back to the static ref", () => {
|
|
66
|
+
expect(() => templateContentVersionRef(definition, "")).toThrow(/content key/);
|
|
67
|
+
expect(() => templateContentVersionRef(definition, " ")).toThrow(/content key/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("long ids truncate to fit the 40-char registry limit", () => {
|
|
71
|
+
const long = defineTemplate({ id: "a".repeat(40), version: "12", title: "Long", install: [] });
|
|
72
|
+
const ref = templateContentVersionRef(long, "rootfs-sha-1");
|
|
73
|
+
expect(ref.length).toBeLessThanOrEqual(40);
|
|
74
|
+
expect(ref).toMatch(/-v12-[0-9a-f]{8}$/);
|
|
75
|
+
expect(templateVersionRef(long).length).toBeLessThanOrEqual(40);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
4
78
|
|
|
5
79
|
describe("SandboxClient template bake", () => {
|
|
6
80
|
test("attributes ownership and publishes private version plus latest pointers", async () => {
|
|
7
|
-
const
|
|
8
|
-
const fakeFetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
9
|
-
const url = new URL(String(input));
|
|
10
|
-
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
11
|
-
calls.push({ method: init?.method ?? "GET", path: url.pathname, body, owner: new Headers(init?.headers).get("x-on-behalf-of") });
|
|
12
|
-
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
|
13
|
-
if (url.pathname === "/v1/sandboxes" && init?.method === "POST") return json({ id: "sb-1" });
|
|
14
|
-
if (url.pathname.endsWith("/files")) return json({ ok: true });
|
|
15
|
-
if (url.pathname.endsWith("/snapshot")) return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z" });
|
|
16
|
-
if (url.pathname === "/v1/snapshots/snap-1") return json({ id: "snap-1", sizeBytes: 42, createdAt: "2026-01-01T00:00:00Z", uploadedToTigris: true });
|
|
17
|
-
if (url.pathname.startsWith("/v1/templates/")) return json({ ok: true });
|
|
18
|
-
throw new Error(`unexpected ${init?.method} ${url.pathname}`);
|
|
19
|
-
}) as typeof fetch;
|
|
81
|
+
const { calls, fakeFetch } = fakeInfra();
|
|
20
82
|
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
21
|
-
const
|
|
83
|
+
const definition = defineTemplate({
|
|
22
84
|
id: "my-agent",
|
|
23
85
|
version: "3",
|
|
24
86
|
title: "Mine",
|
|
25
87
|
ports: [8766],
|
|
26
88
|
install: [],
|
|
27
89
|
start: { command: "bun start", readiness: { port: 8766, path: "/health" } },
|
|
28
|
-
})
|
|
29
|
-
|
|
90
|
+
});
|
|
91
|
+
const expectedRef = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
92
|
+
const result = await bakeTemplate(client, definition);
|
|
93
|
+
expect(result.versionRef).toBe(expectedRef);
|
|
94
|
+
expect(result.reusedExisting).toBe(false);
|
|
30
95
|
expect(calls.every((call) => call.owner === "user-123")).toBe(true);
|
|
31
|
-
expect(calls.filter((call) => call.path.includes("/v1/templates/")).map((call) => call.path)).toEqual([
|
|
32
|
-
|
|
96
|
+
expect(calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST").map((call) => call.path)).toEqual([
|
|
97
|
+
`/v1/templates/${expectedRef}/latest`,
|
|
33
98
|
"/v1/templates/my-agent/latest",
|
|
34
99
|
]);
|
|
35
100
|
expect(calls[0]?.body).toEqual({ ports: [8766], skipAppProcesses: true, projectSlug: "my-agent" });
|
|
36
|
-
expect(calls.filter((call) => call.path.includes("/v1/templates/")).map((call) => call.body)).toEqual([
|
|
101
|
+
expect(calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST").map((call) => call.body)).toEqual([
|
|
37
102
|
{
|
|
38
103
|
snapshotId: "snap-1",
|
|
39
104
|
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
@@ -50,4 +115,46 @@ describe("SandboxClient template bake", () => {
|
|
|
50
115
|
},
|
|
51
116
|
]);
|
|
52
117
|
});
|
|
118
|
+
|
|
119
|
+
test("systemClient publishes the registry entries unscoped even when the sandbox-lifecycle client carries an ownerId", async () => {
|
|
120
|
+
const { calls, fakeFetch } = fakeInfra();
|
|
121
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "svc:template-builder", fetch: fakeFetch });
|
|
122
|
+
const systemClient = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", fetch: fakeFetch });
|
|
123
|
+
await bakeTemplate(client, defineTemplate({
|
|
124
|
+
id: "my-agent",
|
|
125
|
+
version: "3",
|
|
126
|
+
title: "Mine",
|
|
127
|
+
install: [],
|
|
128
|
+
}), { systemClient });
|
|
129
|
+
const sandboxCalls = calls.filter((call) => !call.path.includes("/v1/templates/"));
|
|
130
|
+
const templateCalls = calls.filter((call) => call.path.includes("/v1/templates/"));
|
|
131
|
+
expect(sandboxCalls.every((call) => call.owner === "svc:template-builder")).toBe(true);
|
|
132
|
+
expect(templateCalls.every((call) => call.owner === null)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an already-published content identity is reused: latest repoints, the immutable version is never republished", async () => {
|
|
136
|
+
const definition = defineTemplate({ id: "my-agent", version: "3", title: "Mine", install: [] });
|
|
137
|
+
const ref = templateContentVersionRef(definition, "rootfs-sha-1");
|
|
138
|
+
const { calls, fakeFetch } = fakeInfra({ existingVersions: { [ref]: "snap-old" } });
|
|
139
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
140
|
+
const result = await bakeTemplate(client, definition);
|
|
141
|
+
expect(result.reusedExisting).toBe(true);
|
|
142
|
+
expect(result.versionRef).toBe(ref);
|
|
143
|
+
expect(result.snapshotId).toBe("snap-old");
|
|
144
|
+
const publishes = calls.filter((call) => call.path.includes("/v1/templates/") && call.method === "POST");
|
|
145
|
+
// Never POSTs the immutable ref (would 409); only the mutable latest
|
|
146
|
+
// pointer moves, and it moves to the EXISTING version's snapshot so
|
|
147
|
+
// latest and the immutable ref stay consistent.
|
|
148
|
+
expect(publishes.map((call) => call.path)).toEqual(["/v1/templates/my-agent/latest"]);
|
|
149
|
+
expect(publishes[0]?.body).toMatchObject({ snapshotId: "snap-old" });
|
|
150
|
+
// The redundant duplicate snapshot from this bake is cleaned up.
|
|
151
|
+
expect(calls.some((call) => call.method === "DELETE" && call.path === "/v1/snapshots/snap-1")).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("missing rootfsSha fails loud instead of publishing a collision-prone static ref", async () => {
|
|
155
|
+
const { fakeFetch } = fakeInfra({ snapshotRootfsSha: null });
|
|
156
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
157
|
+
await expect(bakeTemplate(client, defineTemplate({ id: "my-agent", version: "3", title: "Mine", install: [] })))
|
|
158
|
+
.rejects.toThrow(/rootfsSha/);
|
|
159
|
+
});
|
|
53
160
|
});
|
package/src/client.ts
CHANGED
|
@@ -25,6 +25,20 @@ export interface Snapshot {
|
|
|
25
25
|
sizeBytes: number;
|
|
26
26
|
createdAt: string;
|
|
27
27
|
uploadedToTigris?: boolean;
|
|
28
|
+
/** sha256 of the base rootfs the source sandbox was created from. */
|
|
29
|
+
rootfsSha?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Error thrown by SandboxClient.request carrying the HTTP status, so callers
|
|
33
|
+
* can branch on well-defined statuses (e.g. 404 template probe) instead of
|
|
34
|
+
* string-matching error messages. */
|
|
35
|
+
export class SandboxApiError extends Error {
|
|
36
|
+
readonly status: number;
|
|
37
|
+
constructor(message: string, status: number) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "SandboxApiError";
|
|
40
|
+
this.status = status;
|
|
41
|
+
}
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export interface CreateSandboxOptions {
|
|
@@ -98,7 +112,7 @@ export class SandboxClient {
|
|
|
98
112
|
const detail = parsed && typeof parsed === "object" && "error" in parsed
|
|
99
113
|
? String((parsed as { error: unknown }).error)
|
|
100
114
|
: text || `HTTP ${response.status}`;
|
|
101
|
-
throw new
|
|
115
|
+
throw new SandboxApiError(`sandbox API ${method} ${path}: ${response.status} ${detail}`, response.status);
|
|
102
116
|
}
|
|
103
117
|
return parsed as T;
|
|
104
118
|
}
|
|
@@ -112,6 +126,10 @@ export class SandboxClient {
|
|
|
112
126
|
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
async deleteSnapshot(id: string): Promise<void> {
|
|
130
|
+
await this.request("DELETE", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
async publishTemplate(templateId: string, snapshotId: string, runtime?: TemplateRuntimeContract): Promise<void> {
|
|
116
134
|
const id = assertTemplateId(templateId);
|
|
117
135
|
await this.request("POST", `/v1/templates/${id}/latest`, { snapshotId, ...runtime });
|
package/src/templates.test.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { applyTemplate, apt, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateRuntimeContract, templateVersionRef } from "./templates";
|
|
3
4
|
import { agentTemplates } from "../../../templates/agent-catalog";
|
|
5
|
+
import type { Sandbox } from "./client";
|
|
6
|
+
|
|
7
|
+
const FIXTURE_DIR = join(import.meta.dirname, "__fixtures__", "mini-template");
|
|
8
|
+
|
|
9
|
+
function fakeSandbox(): Sandbox & { writeBatches: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>>; shellCalls: string[] } {
|
|
10
|
+
const writeBatches: Array<Array<{ path: string; content: Uint8Array | string; mode?: number }>> = [];
|
|
11
|
+
const shellCalls: string[] = [];
|
|
12
|
+
return {
|
|
13
|
+
id: "fake",
|
|
14
|
+
writeBatches,
|
|
15
|
+
shellCalls,
|
|
16
|
+
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
17
|
+
shell: async (script: string) => {
|
|
18
|
+
shellCalls.push(script);
|
|
19
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
20
|
+
},
|
|
21
|
+
writeFiles: async (batch) => {
|
|
22
|
+
writeBatches.push(batch);
|
|
23
|
+
},
|
|
24
|
+
snapshot: async () => ({ id: "snap", sizeBytes: 0, createdAt: new Date(0).toISOString() }),
|
|
25
|
+
stop: async () => {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
describe("sandbox templates", () => {
|
|
6
30
|
test("defines a typed, immutable versioned template", () => {
|
|
@@ -23,19 +47,76 @@ describe("sandbox templates", () => {
|
|
|
23
47
|
expect(() => compileStep(run({ command: "true", env: { "BAD-NAME": "x" } }))).toThrow("invalid environment variable");
|
|
24
48
|
});
|
|
25
49
|
|
|
50
|
+
test("files.copy requires non-empty sourceDir/destination", () => {
|
|
51
|
+
expect(() => files.copy({ sourceDir: "", destination: "/home/user/app" })).toThrow("sourceDir");
|
|
52
|
+
expect(() => files.copy({ sourceDir: "/tmp/x", destination: "" })).toThrow("destination");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("compileStep rejects files steps — they are applied, not compiled", () => {
|
|
56
|
+
expect(() => compileStep(files.copy({ sourceDir: "/tmp/x", destination: "/home/user/app" }))).toThrow("applyTemplate");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("applyTemplate copies a local directory into the sandbox via writeFiles, skipping .gitkeep", async () => {
|
|
60
|
+
const sandbox = fakeSandbox();
|
|
61
|
+
const definition = defineTemplate({
|
|
62
|
+
id: "files-fixture",
|
|
63
|
+
version: "1",
|
|
64
|
+
title: "files fixture",
|
|
65
|
+
install: [files.copy({ sourceDir: FIXTURE_DIR, destination: "/home/user/app/" })],
|
|
66
|
+
});
|
|
67
|
+
await applyTemplate(sandbox, definition);
|
|
68
|
+
// applyTemplate also writes the manifest (definition.json) after every
|
|
69
|
+
// install step — the files-step batch is whichever call carries our
|
|
70
|
+
// fixture's paths.
|
|
71
|
+
const scaffoldBatch = sandbox.writeBatches.find((batch) => batch.some((f) => f.path.startsWith("/home/user/app/")));
|
|
72
|
+
expect(scaffoldBatch).toBeDefined();
|
|
73
|
+
const paths = scaffoldBatch!.map((f) => f.path).sort();
|
|
74
|
+
expect(paths).toEqual(["/home/user/app/nested/hello.txt", "/home/user/app/package.json"]);
|
|
75
|
+
// trailing slash on destination is normalized, not doubled
|
|
76
|
+
expect(paths.every((p) => !p.includes("//"))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("applyTemplate throws loud when a files step's sourceDir has no files", async () => {
|
|
80
|
+
const sandbox = fakeSandbox();
|
|
81
|
+
const definition = defineTemplate({
|
|
82
|
+
id: "empty-fixture",
|
|
83
|
+
version: "1",
|
|
84
|
+
title: "empty fixture",
|
|
85
|
+
install: [files.copy({ sourceDir: join(FIXTURE_DIR, "nested", "does-not-exist"), destination: "/home/user/app" })],
|
|
86
|
+
});
|
|
87
|
+
await expect(applyTemplate(sandbox, definition)).rejects.toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
26
90
|
test("LFG owns every runtime dependency and zero-config transport default", () => {
|
|
27
91
|
const lfg = agentTemplates.lfg;
|
|
28
|
-
expect(templateVersionRef(lfg)).toBe("agent-lfg-
|
|
92
|
+
expect(templateVersionRef(lfg)).toBe("agent-lfg-v14");
|
|
29
93
|
expect(lfg.install).toContainEqual(apt.packages(["tmux"]));
|
|
30
94
|
expect(lfg.checks).toContainEqual(check.command("tmux"));
|
|
31
95
|
expect(lfg.start?.env?.LIVE_TRANSPORT).toBe("ws");
|
|
32
96
|
expect(lfg.start?.readiness?.port).toBe(8766);
|
|
33
97
|
expect(templateRuntimeContract(lfg, templateVersionRef(lfg))).toEqual({
|
|
34
|
-
immutableRef: "agent-lfg-
|
|
98
|
+
immutableRef: "agent-lfg-v14",
|
|
35
99
|
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
36
100
|
readinessPort: 8766,
|
|
37
101
|
readinessPath: "/",
|
|
38
102
|
ports: [8766],
|
|
39
103
|
});
|
|
40
104
|
});
|
|
105
|
+
|
|
106
|
+
test("LFG bakes the react-ts scaffold into /home/user/app at bake time", () => {
|
|
107
|
+
const lfg = agentTemplates.lfg;
|
|
108
|
+
const filesSteps = lfg.install.filter((step) => step.kind === "files");
|
|
109
|
+
expect(filesSteps).toHaveLength(1);
|
|
110
|
+
const scaffold = filesSteps[0] as Extract<(typeof filesSteps)[number], { kind: "files" }>;
|
|
111
|
+
expect(scaffold.destination).toBe("/home/user/app");
|
|
112
|
+
expect(scaffold.sourceDir.endsWith(join("templates", "react-ts"))).toBe(true);
|
|
113
|
+
// recipe.sh (shadcn init) must run against the baked scaffold, not just
|
|
114
|
+
// land on disk unused — otherwise Computers still improvise shadcn init
|
|
115
|
+
// on first turn and the whole point of baking is lost.
|
|
116
|
+
const commands = lfg.install
|
|
117
|
+
.filter((step): step is Extract<typeof step, { kind: "run" }> => step.kind === "run")
|
|
118
|
+
.map((step) => step.command);
|
|
119
|
+
expect(commands.some((c) => c.includes("recipe.sh"))).toBe(true);
|
|
120
|
+
expect(lfg.metadata?.lfgRelease).toBe("v0.1.39");
|
|
121
|
+
});
|
|
41
122
|
});
|
package/src/templates.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
2
4
|
import type { Sandbox, SandboxClient, Snapshot } from "./client.js";
|
|
3
|
-
import { assertTemplateId } from "./client.js";
|
|
5
|
+
import { assertTemplateId, SandboxApiError } from "./client.js";
|
|
4
6
|
|
|
5
7
|
export type TemplateStep =
|
|
6
8
|
| { kind: "apt"; packages: readonly string[] }
|
|
7
9
|
| { kind: "archive"; url: string; destination: string; stripComponents?: number }
|
|
8
|
-
| { kind: "run"; command: string; cwd?: string; user?: string; env?: Record<string, string>; timeoutMs?: number }
|
|
10
|
+
| { kind: "run"; command: string; cwd?: string; user?: string; env?: Record<string, string>; timeoutMs?: number }
|
|
11
|
+
// Copies a local directory (resolved by the caller at definition-authoring
|
|
12
|
+
// time — e.g. templates/react-ts) into the sandbox at bake time. Unlike the
|
|
13
|
+
// other steps this is NOT a shell script: compileStep() rejects it, and
|
|
14
|
+
// applyTemplate() special-cases it to walk sourceDir on the local
|
|
15
|
+
// filesystem (the bake runs from a full monorepo checkout) and batch
|
|
16
|
+
// sandbox.writeFiles() calls, same pattern as build-template.ts's own
|
|
17
|
+
// walkTemplate/writeFilesBatched for the legacy (non-typed) templates.
|
|
18
|
+
| { kind: "files"; sourceDir: string; destination: string };
|
|
9
19
|
|
|
10
20
|
export type TemplateCheck =
|
|
11
21
|
| { kind: "command"; command: string; user?: string }
|
|
@@ -36,6 +46,10 @@ export interface BakeResult {
|
|
|
36
46
|
versionRef: string;
|
|
37
47
|
definitionHash: string;
|
|
38
48
|
snapshotId: string;
|
|
49
|
+
/** True when this exact content identity (definition + base rootfs) was
|
|
50
|
+
* already published: the existing immutable version was reused and no new
|
|
51
|
+
* registry version was created. */
|
|
52
|
+
reusedExisting: boolean;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
export const apt = {
|
|
@@ -56,6 +70,14 @@ export function run(options: Omit<Extract<TemplateStep, { kind: "run" }>, "kind"
|
|
|
56
70
|
return { kind: "run", ...options };
|
|
57
71
|
}
|
|
58
72
|
|
|
73
|
+
export const files = {
|
|
74
|
+
copy(options: { sourceDir: string; destination: string }): TemplateStep {
|
|
75
|
+
if (!options.sourceDir.trim()) throw new Error("files.copy requires a sourceDir");
|
|
76
|
+
if (!options.destination.trim()) throw new Error("files.copy requires a destination");
|
|
77
|
+
return { kind: "files", ...options };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
59
81
|
export const check = {
|
|
60
82
|
command(command: string, options: { user?: string } = {}): TemplateCheck {
|
|
61
83
|
return { kind: "command", command, ...options };
|
|
@@ -73,11 +95,38 @@ export function defineTemplate<const T extends SandboxTemplate>(definition: T):
|
|
|
73
95
|
return Object.freeze(definition);
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
|
|
77
|
-
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
98
|
+
function refWithSuffix(id: string, suffix: string): string {
|
|
78
99
|
const maxBase = 40 - suffix.length;
|
|
79
100
|
if (maxBase < 2) throw new Error("template id and version are too long for registry");
|
|
80
|
-
return assertTemplateId(`${
|
|
101
|
+
return assertTemplateId(`${id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function templateVersionRef(definition: SandboxTemplate): string {
|
|
105
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// templateContentVersionRef is the identity a bake publishes as its immutable
|
|
109
|
+
// registry version: <id>-v<version>-<hash8>, where the hash covers BOTH the
|
|
110
|
+
// template definition and the base image the bake ran on (contentKey — the
|
|
111
|
+
// snapshot's rootfsSha). Registry versions are immutable, so the ref MUST
|
|
112
|
+
// change whenever the baked content can differ. The hand-bumped catalog
|
|
113
|
+
// `version` alone cannot express that: a rootfs/agent change alters every
|
|
114
|
+
// bake's output without touching the catalog, and republishing the same
|
|
115
|
+
// static ref (e.g. agent-claude-v2) with a new snapshot is a 409 immutability
|
|
116
|
+
// violation by design. Content-addressing the ref makes that collision
|
|
117
|
+
// structurally impossible — new content → new ref; identical content → same
|
|
118
|
+
// ref, which bakeTemplate resolves and reuses instead of republishing.
|
|
119
|
+
export function templateContentVersionRef(definition: SandboxTemplate, contentKey: string): string {
|
|
120
|
+
if (!contentKey.trim()) {
|
|
121
|
+
// No silent fallback to the static ref: an empty content key would revert
|
|
122
|
+
// to exactly the colliding identity this function exists to prevent.
|
|
123
|
+
throw new Error("template content key (base rootfs sha) is required");
|
|
124
|
+
}
|
|
125
|
+
const hash = createHash("sha256")
|
|
126
|
+
.update(`${templateDefinitionHash(definition)}\n${contentKey.trim()}`)
|
|
127
|
+
.digest("hex")
|
|
128
|
+
.slice(0, 8);
|
|
129
|
+
return refWithSuffix(definition.id, `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}-${hash}`);
|
|
81
130
|
}
|
|
82
131
|
|
|
83
132
|
export function templateDefinitionHash(definition: SandboxTemplate): string {
|
|
@@ -96,10 +145,21 @@ export function templateRuntimeContract(definition: SandboxTemplate, immutableRe
|
|
|
96
145
|
|
|
97
146
|
function quote(value: string): string { return `'${value.replaceAll("'", `'\"'\"'`)}'`; }
|
|
98
147
|
|
|
148
|
+
// Manager.Exec (apps/infra) injects the platform LLM-proxy vars into every
|
|
149
|
+
// exec call's own root-level env, but "sudo -u <user>" resets the
|
|
150
|
+
// environment by default and (absent this) only HOME/PATH were forwarded —
|
|
151
|
+
// a template's `start` command (e.g. lfg's pi backend) would otherwise see
|
|
152
|
+
// real-Anthropic 401s instead of the sandbox proxy. Forwarding them as
|
|
153
|
+
// literal VAR=value args to env(1) is not "preserving" the invoking shell's
|
|
154
|
+
// env (which sudoers' env_reset would still block) — it is env(1) itself
|
|
155
|
+
// setting them in the de-privileged child, which needs no sudoers policy.
|
|
156
|
+
const PROXY_ENV_VARS = ["OMG_AI_URL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OMG_MEDIA_URL"];
|
|
157
|
+
|
|
99
158
|
function commandForUser(command: string, user?: string): string {
|
|
100
159
|
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
101
160
|
const home = user === "root" ? "/root" : `/home/${user}`;
|
|
102
|
-
|
|
161
|
+
const proxyEnv = PROXY_ENV_VARS.map((name) => `${name}="\${${name}:-}"`).join(" ");
|
|
162
|
+
return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} ${proxyEnv} /bin/bash -c ${quote(command)}`;
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
function shellEnvAssignments(env: Record<string, string>): string[] {
|
|
@@ -110,6 +170,9 @@ function shellEnvAssignments(env: Record<string, string>): string[] {
|
|
|
110
170
|
}
|
|
111
171
|
|
|
112
172
|
export function compileStep(step: TemplateStep): { script: string; timeoutMs?: number } {
|
|
173
|
+
if (step.kind === "files") {
|
|
174
|
+
throw new Error("files steps transfer local content and are applied via applyTemplate(), not compileStep()");
|
|
175
|
+
}
|
|
113
176
|
if (step.kind === "apt") {
|
|
114
177
|
return { script: `set -euo pipefail\napt-get update -qq\nDEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends ${step.packages.map(quote).join(" ")}\nrm -rf /var/lib/apt/lists/*` };
|
|
115
178
|
}
|
|
@@ -140,9 +203,53 @@ async function assertExec(label: string, result: { exitCode: number; stdout: str
|
|
|
140
203
|
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
|
|
207
|
+
|
|
208
|
+
async function walkSourceDir(dir: string): Promise<Array<{ relPath: string; buf: Buffer }>> {
|
|
209
|
+
const out: Array<{ relPath: string; buf: Buffer }> = [];
|
|
210
|
+
async function visit(d: string): Promise<void> {
|
|
211
|
+
const entries = await readdir(d, { withFileTypes: true });
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const full = join(d, entry.name);
|
|
214
|
+
if (entry.isDirectory()) {
|
|
215
|
+
await visit(full);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
|
|
219
|
+
out.push({ relPath: relative(dir, full), buf: await readFile(full) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await visit(dir);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function applyFilesStep(
|
|
227
|
+
sandbox: Sandbox,
|
|
228
|
+
step: Extract<TemplateStep, { kind: "files" }>,
|
|
229
|
+
onLog: (line: string) => void,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const walked = await walkSourceDir(step.sourceDir);
|
|
232
|
+
if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
|
|
233
|
+
const destination = step.destination.replace(/\/$/, "");
|
|
234
|
+
const batchSize = 50;
|
|
235
|
+
for (let i = 0; i < walked.length; i += batchSize) {
|
|
236
|
+
const batch = walked.slice(i, i + batchSize).map((f) => ({
|
|
237
|
+
path: `${destination}/${f.relPath}`,
|
|
238
|
+
content: f.buf,
|
|
239
|
+
mode: 0o644,
|
|
240
|
+
}));
|
|
241
|
+
await sandbox.writeFiles(batch);
|
|
242
|
+
}
|
|
243
|
+
onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
143
246
|
export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplate, onLog: (line: string) => void = () => {}): Promise<void> {
|
|
144
247
|
for (const [index, step] of definition.install.entries()) {
|
|
145
248
|
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
249
|
+
if (step.kind === "files") {
|
|
250
|
+
await applyFilesStep(sandbox, step, onLog);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
146
253
|
const compiled = compileStep(step);
|
|
147
254
|
await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 60_000 }));
|
|
148
255
|
}
|
|
@@ -157,33 +264,76 @@ export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplat
|
|
|
157
264
|
}
|
|
158
265
|
}
|
|
159
266
|
|
|
160
|
-
export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<
|
|
267
|
+
export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<Snapshot> {
|
|
161
268
|
const deadline = Date.now() + timeoutMs;
|
|
162
269
|
while (Date.now() < deadline) {
|
|
163
|
-
|
|
270
|
+
const uploaded = await client.getSnapshot(snapshot.id);
|
|
271
|
+
if (uploaded.uploadedToTigris) return uploaded;
|
|
164
272
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
165
273
|
}
|
|
166
274
|
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
167
275
|
}
|
|
168
276
|
|
|
169
|
-
export async function bakeTemplate(
|
|
277
|
+
export async function bakeTemplate(
|
|
278
|
+
client: SandboxClient,
|
|
279
|
+
definition: SandboxTemplate,
|
|
280
|
+
options: { onLog?: (line: string) => void; publishLatest?: boolean; systemClient?: SandboxClient } = {},
|
|
281
|
+
): Promise<BakeResult> {
|
|
170
282
|
const log = options.onLog ?? (() => {});
|
|
171
|
-
const versionRef = templateVersionRef(definition);
|
|
172
283
|
const sandbox = await client.create({ ports: [...(definition.ports ?? [])], skipAppProcesses: true, projectSlug: definition.id });
|
|
173
284
|
let snapshotted = false;
|
|
174
285
|
try {
|
|
175
286
|
await applyTemplate(sandbox, definition, log);
|
|
176
287
|
const snapshot = await sandbox.snapshot();
|
|
177
288
|
snapshotted = true;
|
|
178
|
-
await waitForSnapshotUpload(client, snapshot);
|
|
289
|
+
const uploaded = await waitForSnapshotUpload(client, snapshot);
|
|
290
|
+
// The immutable version ref is content-addressed: definition hash + the
|
|
291
|
+
// base rootfs the bake actually ran on (recorded on the snapshot). See
|
|
292
|
+
// templateContentVersionRef — this is what lets a rootfs/agent-triggered
|
|
293
|
+
// rebake mint a NEW version instead of 409ing against the previous one.
|
|
294
|
+
// Fail loud if the API did not report a rootfs sha; falling back to the
|
|
295
|
+
// static <id>-v<version> ref would reintroduce the collision.
|
|
296
|
+
const rootfsSha = uploaded.rootfsSha?.trim();
|
|
297
|
+
if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha — cannot derive an immutable template version identity`);
|
|
298
|
+
const versionRef = templateContentVersionRef(definition, rootfsSha);
|
|
179
299
|
// A client with ownerId acts on behalf of a user and publishes into that
|
|
180
300
|
// user's private namespace. Only the direct service client may mark a
|
|
181
|
-
// global system version immutable.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
301
|
+
// global system version immutable. `client` may need an ownerId just to
|
|
302
|
+
// pass the sandbox-ownership check on create/exec/snapshot (a bare
|
|
303
|
+
// service-token call with no owner has caller="" and 403s there) — that
|
|
304
|
+
// is orthogonal to whether THIS bake should publish globally. Callers
|
|
305
|
+
// baking an official, system-wide catalog template (not a private/scoped
|
|
306
|
+
// one) pass options.systemClient (no ownerId) so the registry publish
|
|
307
|
+
// itself lands in the global namespace regardless of what identity was
|
|
308
|
+
// needed upstream for the sandbox lifecycle calls.
|
|
309
|
+
const publishClient = options.systemClient ?? client;
|
|
310
|
+
const runtime = templateRuntimeContract(definition, publishClient.ownerId ? undefined : versionRef);
|
|
311
|
+
// Same content identity already published (an unchanged re-run, or a
|
|
312
|
+
// partially-failed run being re-executed after some templates landed):
|
|
313
|
+
// registry versions are immutable, so NEVER republish the ref. Reuse the
|
|
314
|
+
// existing version's snapshot — repoint only the mutable latest pointer
|
|
315
|
+
// at it, and drop this run's redundant duplicate snapshot (best-effort;
|
|
316
|
+
// an orphan is only storage, never correctness).
|
|
317
|
+
const existing = await resolveTemplateVersion(publishClient, versionRef);
|
|
318
|
+
if (existing) {
|
|
319
|
+
log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
|
|
320
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
|
|
321
|
+
await client.deleteSnapshot(snapshot.id).catch(() => {});
|
|
322
|
+
return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: existing.snapshotId, reusedExisting: true };
|
|
323
|
+
}
|
|
324
|
+
await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
|
|
325
|
+
if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
|
|
326
|
+
return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: snapshot.id, reusedExisting: false };
|
|
186
327
|
} finally {
|
|
187
328
|
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
188
329
|
}
|
|
189
330
|
}
|
|
331
|
+
|
|
332
|
+
async function resolveTemplateVersion(client: SandboxClient, versionRef: string): Promise<{ snapshotId: string } | undefined> {
|
|
333
|
+
try {
|
|
334
|
+
return await client.resolveTemplate(versionRef);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (err instanceof SandboxApiError && err.status === 404) return undefined;
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
}
|