@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 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-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 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,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
- if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
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
- 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);
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, 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 };
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 };
@@ -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-D2llpqYs.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.25",
3
+ "version": "0.4.27",
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,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-v10");
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-v10",
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
- 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,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<void> {
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
- if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
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(client: SandboxClient, definition: SandboxTemplate, options: { onLog?: (line: string) => void; publishLatest?: boolean } = {}): Promise<BakeResult> {
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
- 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 };
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
+ }