@omg-dev/sandbox 0.4.26 → 0.4.28

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 { a as compileStartScript, c as download, d as templateRuntimeContract, f as templateVersionRef, h as assertTemplateId, i as check, l as run, m as SandboxClient, n as apt, o as compileStep, p as waitForSnapshotUpload, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as templateDefinitionHash } from "./templates-CdQWcmOC.mjs";
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-C3mBSGdT.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
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } 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 Error(`sandbox API ${method} ${path}: ${response.status} ${detail}`);
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 templateVersionRef(definition) {
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(`${definition.id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
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
- return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} /bin/bash -c ${quote(command)}`;
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,11 +228,113 @@ 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
+ 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
+ }
292
+ const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
293
+ async function walkSourceDir(dir) {
294
+ const out = [];
295
+ async function visit(d) {
296
+ const entries = await readdir(d, { withFileTypes: true });
297
+ for (const entry of entries) {
298
+ const full = join(d, entry.name);
299
+ if (entry.isDirectory()) {
300
+ await visit(full);
301
+ continue;
302
+ }
303
+ if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
304
+ out.push({
305
+ relPath: relative(dir, full),
306
+ buf: await readFile(full)
307
+ });
308
+ }
309
+ }
310
+ await visit(dir);
311
+ return out;
312
+ }
313
+ async function applyFilesStep(sandbox, step, onLog) {
314
+ const walked = await walkSourceDir(step.sourceDir);
315
+ if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
316
+ const destination = step.destination.replace(/\/$/, "");
317
+ const batchSize = 50;
318
+ for (let i = 0; i < walked.length; i += batchSize) {
319
+ const batch = walked.slice(i, i + batchSize).map((f) => ({
320
+ path: `${destination}/${f.relPath}`,
321
+ content: f.buf,
322
+ mode: 420
323
+ }));
324
+ await sandbox.writeFiles(batch);
325
+ }
326
+ onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
327
+ }
190
328
  async function applyTemplate(sandbox, definition, onLog = () => {}) {
191
329
  for (const [index, step] of definition.install.entries()) {
192
330
  onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
331
+ if (step.kind === "files") {
332
+ await applyFilesStep(sandbox, step, onLog);
333
+ continue;
334
+ }
193
335
  const compiled = compileStep(step);
194
- await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 6e4 }));
336
+ const label = `template step ${index + 1}`;
337
+ await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 6e4));
195
338
  }
196
339
  if (definition.start) await sandbox.writeFiles([{
197
340
  path: "/home/user/.omg/template/bootstrap.sh",
@@ -215,14 +358,14 @@ async function applyTemplate(sandbox, definition, onLog = () => {}) {
215
358
  async function waitForSnapshotUpload(client, snapshot, timeoutMs = 10 * 6e4) {
216
359
  const deadline = Date.now() + timeoutMs;
217
360
  while (Date.now() < deadline) {
218
- if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
361
+ const uploaded = await client.getSnapshot(snapshot.id);
362
+ if (uploaded.uploadedToTigris) return uploaded;
219
363
  await new Promise((resolve) => setTimeout(resolve, 2e3));
220
364
  }
221
365
  throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
222
366
  }
223
367
  async function bakeTemplate(client, definition, options = {}) {
224
368
  const log = options.onLog ?? (() => {});
225
- const versionRef = templateVersionRef(definition);
226
369
  const sandbox = await client.create({
227
370
  ports: [...definition.ports ?? []],
228
371
  skipAppProcesses: true,
@@ -233,19 +376,44 @@ async function bakeTemplate(client, definition, options = {}) {
233
376
  await applyTemplate(sandbox, definition, log);
234
377
  const snapshot = await sandbox.snapshot();
235
378
  snapshotted = true;
236
- await waitForSnapshotUpload(client, snapshot);
237
- const runtime = templateRuntimeContract(definition, client.ownerId ? void 0 : versionRef);
238
- await client.publishTemplate(versionRef, snapshot.id, runtime);
239
- if (options.publishLatest !== false) await client.publishTemplate(definition.id, snapshot.id, runtime);
379
+ const rootfsSha = (await waitForSnapshotUpload(client, snapshot)).rootfsSha?.trim();
380
+ if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha cannot derive an immutable template version identity`);
381
+ const versionRef = templateContentVersionRef(definition, rootfsSha);
382
+ const publishClient = options.systemClient ?? client;
383
+ const runtime = templateRuntimeContract(definition, publishClient.ownerId ? void 0 : versionRef);
384
+ const existing = await resolveTemplateVersion(publishClient, versionRef);
385
+ if (existing) {
386
+ log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
387
+ if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
388
+ await client.deleteSnapshot(snapshot.id).catch(() => {});
389
+ return {
390
+ templateId: definition.id,
391
+ versionRef,
392
+ definitionHash: templateDefinitionHash(definition),
393
+ snapshotId: existing.snapshotId,
394
+ reusedExisting: true
395
+ };
396
+ }
397
+ await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
398
+ if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
240
399
  return {
241
400
  templateId: definition.id,
242
401
  versionRef,
243
402
  definitionHash: templateDefinitionHash(definition),
244
- snapshotId: snapshot.id
403
+ snapshotId: snapshot.id,
404
+ reusedExisting: false
245
405
  };
246
406
  } finally {
247
407
  if (!snapshotted) await sandbox.stop().catch(() => {});
248
408
  }
249
409
  }
410
+ async function resolveTemplateVersion(client, versionRef) {
411
+ try {
412
+ return await client.resolveTemplate(versionRef);
413
+ } catch (err) {
414
+ if (err instanceof SandboxApiError && err.status === 404) return void 0;
415
+ throw err;
416
+ }
417
+ }
250
418
  //#endregion
251
- export { compileStartScript as a, download as c, templateRuntimeContract as d, templateVersionRef as f, assertTemplateId as h, check as i, run as l, SandboxClient as m, apt as n, compileStep as o, waitForSnapshotUpload as p, bakeTemplate as r, defineTemplate as s, applyTemplate as t, templateDefinitionHash as u };
419
+ 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 };
@@ -1,2 +1,2 @@
1
- import { a as compileStartScript, c as download, d as templateRuntimeContract, f as templateVersionRef, i as check, l as run, n as apt, o as compileStep, p as waitForSnapshotUpload, r as bakeTemplate, s as defineTemplate, t as applyTemplate, u as templateDefinitionHash } from "./templates-CdQWcmOC.mjs";
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-C3mBSGdT.mjs";
2
+ export { applyTemplate, apt, bakeTemplate, check, compileStartScript, compileStep, defineTemplate, download, files, run, templateContentVersionRef, templateDefinitionHash, templateRuntimeContract, templateVersionRef, waitForSnapshotUpload };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omg-dev/sandbox",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
File without changes
@@ -0,0 +1 @@
1
+ hello from the fixture
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "mini-template-fixture",
3
+ "private": true
4
+ }
@@ -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 calls: Array<{ method: string; path: string; body?: unknown; owner?: string | null }> = [];
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 result = await bakeTemplate(client, defineTemplate({
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
- expect(result.versionRef).toBe("my-agent-v3");
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
- "/v1/templates/my-agent-v3/latest",
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 Error(`sandbox API ${method} ${path}: ${response.status} ${detail}`);
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 });
@@ -1,6 +1,30 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { apt, check, compileStartScript, compileStep, defineTemplate, download, run, templateRuntimeContract, templateVersionRef } from "./templates";
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,128 @@ 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
+
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
+
26
142
  test("LFG owns every runtime dependency and zero-config transport default", () => {
27
143
  const lfg = agentTemplates.lfg;
28
- expect(templateVersionRef(lfg)).toBe("agent-lfg-v10");
144
+ expect(templateVersionRef(lfg)).toBe("agent-lfg-v17");
29
145
  expect(lfg.install).toContainEqual(apt.packages(["tmux"]));
30
146
  expect(lfg.checks).toContainEqual(check.command("tmux"));
31
147
  expect(lfg.start?.env?.LIVE_TRANSPORT).toBe("ws");
32
148
  expect(lfg.start?.readiness?.port).toBe(8766);
33
149
  expect(templateRuntimeContract(lfg, templateVersionRef(lfg))).toEqual({
34
- immutableRef: "agent-lfg-v10",
150
+ immutableRef: "agent-lfg-v17",
35
151
  startCommand: "exec /home/user/.omg/template/bootstrap.sh",
36
152
  readinessPort: 8766,
37
153
  readinessPath: "/",
38
- ports: [8766],
154
+ ports: [8766, 5173],
39
155
  });
40
156
  });
157
+
158
+ test("LFG bakes the react-ts scaffold into /home/user/project at bake time", () => {
159
+ const lfg = agentTemplates.lfg;
160
+ const filesSteps = lfg.install.filter((step) => step.kind === "files");
161
+ expect(filesSteps).toHaveLength(1);
162
+ const scaffold = filesSteps[0] as Extract<(typeof filesSteps)[number], { kind: "files" }>;
163
+ expect(scaffold.destination).toBe("/home/user/project");
164
+ expect(scaffold.sourceDir.endsWith(join("templates", "react-ts"))).toBe(true);
165
+ // recipe.sh (shadcn init) must run against the baked scaffold, not just
166
+ // land on disk unused — otherwise Computers still improvise shadcn init
167
+ // on first turn and the whole point of baking is lost.
168
+ const commands = lfg.install
169
+ .filter((step): step is Extract<typeof step, { kind: "run" }> => step.kind === "run")
170
+ .map((step) => step.command);
171
+ expect(commands.some((c) => c.includes("recipe.sh"))).toBe(true);
172
+ expect(lfg.metadata?.lfgRelease).toBe("v0.1.40");
173
+ });
41
174
  });
package/src/templates.ts CHANGED
@@ -1,11 +1,21 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } 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
- export function templateVersionRef(definition: SandboxTemplate): string {
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(`${definition.id.slice(0, maxBase).replace(/-+$/, "")}${suffix}`);
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
- return `sudo -u ${quote(user)} -H env HOME=${quote(home)} PATH=${quote(`${home}/.bun/bin:/usr/local/bin:/usr/bin:/bin`)} /bin/bash -c ${quote(command)}`;
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,11 +203,127 @@ 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
+ // 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
+
277
+ const FILES_STEP_SKIP_NAMES = new Set([".gitkeep", ".DS_Store"]);
278
+
279
+ async function walkSourceDir(dir: string): Promise<Array<{ relPath: string; buf: Buffer }>> {
280
+ const out: Array<{ relPath: string; buf: Buffer }> = [];
281
+ async function visit(d: string): Promise<void> {
282
+ const entries = await readdir(d, { withFileTypes: true });
283
+ for (const entry of entries) {
284
+ const full = join(d, entry.name);
285
+ if (entry.isDirectory()) {
286
+ await visit(full);
287
+ continue;
288
+ }
289
+ if (!entry.isFile() || FILES_STEP_SKIP_NAMES.has(entry.name)) continue;
290
+ out.push({ relPath: relative(dir, full), buf: await readFile(full) });
291
+ }
292
+ }
293
+ await visit(dir);
294
+ return out;
295
+ }
296
+
297
+ async function applyFilesStep(
298
+ sandbox: Sandbox,
299
+ step: Extract<TemplateStep, { kind: "files" }>,
300
+ onLog: (line: string) => void,
301
+ ): Promise<void> {
302
+ const walked = await walkSourceDir(step.sourceDir);
303
+ if (!walked.length) throw new Error(`files step: no files found under ${step.sourceDir}`);
304
+ const destination = step.destination.replace(/\/$/, "");
305
+ const batchSize = 50;
306
+ for (let i = 0; i < walked.length; i += batchSize) {
307
+ const batch = walked.slice(i, i + batchSize).map((f) => ({
308
+ path: `${destination}/${f.relPath}`,
309
+ content: f.buf,
310
+ mode: 0o644,
311
+ }));
312
+ await sandbox.writeFiles(batch);
313
+ }
314
+ onLog(`files: wrote ${walked.length} files from ${step.sourceDir} to ${destination}`);
315
+ }
316
+
143
317
  export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplate, onLog: (line: string) => void = () => {}): Promise<void> {
144
318
  for (const [index, step] of definition.install.entries()) {
145
319
  onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
320
+ if (step.kind === "files") {
321
+ await applyFilesStep(sandbox, step, onLog);
322
+ continue;
323
+ }
146
324
  const compiled = compileStep(step);
147
- await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 60_000 }));
325
+ const label = `template step ${index + 1}`;
326
+ await assertExec(label, await runTemplateStep(sandbox, label, compiled.script, compiled.timeoutMs ?? 10 * 60_000));
148
327
  }
149
328
  if (definition.start) {
150
329
  await sandbox.writeFiles([{ path: "/home/user/.omg/template/bootstrap.sh", content: compileStartScript(definition.start), mode: 0o755 }]);
@@ -157,33 +336,76 @@ export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplat
157
336
  }
158
337
  }
159
338
 
160
- export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<void> {
339
+ export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<Snapshot> {
161
340
  const deadline = Date.now() + timeoutMs;
162
341
  while (Date.now() < deadline) {
163
- if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
342
+ const uploaded = await client.getSnapshot(snapshot.id);
343
+ if (uploaded.uploadedToTigris) return uploaded;
164
344
  await new Promise((resolve) => setTimeout(resolve, 2_000));
165
345
  }
166
346
  throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
167
347
  }
168
348
 
169
- export async function bakeTemplate(client: SandboxClient, definition: SandboxTemplate, options: { onLog?: (line: string) => void; publishLatest?: boolean } = {}): Promise<BakeResult> {
349
+ export async function bakeTemplate(
350
+ client: SandboxClient,
351
+ definition: SandboxTemplate,
352
+ options: { onLog?: (line: string) => void; publishLatest?: boolean; systemClient?: SandboxClient } = {},
353
+ ): Promise<BakeResult> {
170
354
  const log = options.onLog ?? (() => {});
171
- const versionRef = templateVersionRef(definition);
172
355
  const sandbox = await client.create({ ports: [...(definition.ports ?? [])], skipAppProcesses: true, projectSlug: definition.id });
173
356
  let snapshotted = false;
174
357
  try {
175
358
  await applyTemplate(sandbox, definition, log);
176
359
  const snapshot = await sandbox.snapshot();
177
360
  snapshotted = true;
178
- await waitForSnapshotUpload(client, snapshot);
361
+ const uploaded = await waitForSnapshotUpload(client, snapshot);
362
+ // The immutable version ref is content-addressed: definition hash + the
363
+ // base rootfs the bake actually ran on (recorded on the snapshot). See
364
+ // templateContentVersionRef — this is what lets a rootfs/agent-triggered
365
+ // rebake mint a NEW version instead of 409ing against the previous one.
366
+ // Fail loud if the API did not report a rootfs sha; falling back to the
367
+ // static <id>-v<version> ref would reintroduce the collision.
368
+ const rootfsSha = uploaded.rootfsSha?.trim();
369
+ if (!rootfsSha) throw new Error(`snapshot ${snapshot.id} has no rootfsSha — cannot derive an immutable template version identity`);
370
+ const versionRef = templateContentVersionRef(definition, rootfsSha);
179
371
  // A client with ownerId acts on behalf of a user and publishes into that
180
372
  // user's private namespace. Only the direct service client may mark a
181
- // global system version immutable.
182
- const runtime = templateRuntimeContract(definition, client.ownerId ? undefined : versionRef);
183
- await client.publishTemplate(versionRef, snapshot.id, runtime);
184
- if (options.publishLatest !== false) await client.publishTemplate(definition.id, snapshot.id, runtime);
185
- return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: snapshot.id };
373
+ // global system version immutable. `client` may need an ownerId just to
374
+ // pass the sandbox-ownership check on create/exec/snapshot (a bare
375
+ // service-token call with no owner has caller="" and 403s there) — that
376
+ // is orthogonal to whether THIS bake should publish globally. Callers
377
+ // baking an official, system-wide catalog template (not a private/scoped
378
+ // one) pass options.systemClient (no ownerId) so the registry publish
379
+ // itself lands in the global namespace regardless of what identity was
380
+ // needed upstream for the sandbox lifecycle calls.
381
+ const publishClient = options.systemClient ?? client;
382
+ const runtime = templateRuntimeContract(definition, publishClient.ownerId ? undefined : versionRef);
383
+ // Same content identity already published (an unchanged re-run, or a
384
+ // partially-failed run being re-executed after some templates landed):
385
+ // registry versions are immutable, so NEVER republish the ref. Reuse the
386
+ // existing version's snapshot — repoint only the mutable latest pointer
387
+ // at it, and drop this run's redundant duplicate snapshot (best-effort;
388
+ // an orphan is only storage, never correctness).
389
+ const existing = await resolveTemplateVersion(publishClient, versionRef);
390
+ if (existing) {
391
+ log(`version ${versionRef} already published (snapshot ${existing.snapshotId}) — reusing, this bake's snapshot is redundant`);
392
+ if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, existing.snapshotId, runtime);
393
+ await client.deleteSnapshot(snapshot.id).catch(() => {});
394
+ return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: existing.snapshotId, reusedExisting: true };
395
+ }
396
+ await publishClient.publishTemplate(versionRef, snapshot.id, runtime);
397
+ if (options.publishLatest !== false) await publishClient.publishTemplate(definition.id, snapshot.id, runtime);
398
+ return { templateId: definition.id, versionRef, definitionHash: templateDefinitionHash(definition), snapshotId: snapshot.id, reusedExisting: false };
186
399
  } finally {
187
400
  if (!snapshotted) await sandbox.stop().catch(() => {});
188
401
  }
189
402
  }
403
+
404
+ async function resolveTemplateVersion(client: SandboxClient, versionRef: string): Promise<{ snapshotId: string } | undefined> {
405
+ try {
406
+ return await client.resolveTemplate(versionRef);
407
+ } catch (err) {
408
+ if (err instanceof SandboxApiError && err.status === 404) return undefined;
409
+ throw err;
410
+ }
411
+ }