@crewhaus/sandbox-image-registry 0.1.0

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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/sandbox-image-registry",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Runtime allow-list registry for sandbox images: registerSandboxImage / lookupSandboxImage / listSandboxImages with healthcheck contract (Section 36)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/sandbox": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/sandbox-image-registry"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/sandbox-image-registry#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,304 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ ImageNotFoundError,
4
+ ImageRegistrationError,
5
+ _resetSandboxImageRegistry,
6
+ hasSandboxImage,
7
+ listAllowedImageRefs,
8
+ listSandboxImages,
9
+ lookupSandboxImage,
10
+ markHealthy,
11
+ markUnhealthy,
12
+ registerSandboxImage,
13
+ runHealthchecks,
14
+ snapshotImageStatuses,
15
+ } from "./index";
16
+
17
+ describe("registerSandboxImage / lookupSandboxImage", () => {
18
+ beforeEach(() => {
19
+ _resetSandboxImageRegistry();
20
+ });
21
+ afterEach(() => {
22
+ _resetSandboxImageRegistry();
23
+ });
24
+
25
+ test("auto-registers the §18 trio (python / javascript / shell)", () => {
26
+ expect(hasSandboxImage("python")).toBe(true);
27
+ expect(hasSandboxImage("javascript")).toBe(true);
28
+ expect(hasSandboxImage("shell")).toBe(true);
29
+ const py = lookupSandboxImage("python");
30
+ expect(py.image).toBe("python:3.13-slim");
31
+ expect(py.defaultEntrypoint).toEqual(["python3", "-c"]);
32
+ const js = lookupSandboxImage("javascript");
33
+ expect(js.image).toBe("node:22-alpine");
34
+ const sh = lookupSandboxImage("shell");
35
+ expect(sh.image).toBe("alpine:3.19");
36
+ });
37
+
38
+ test("registers a new image with a healthcheck contract", () => {
39
+ const entry = registerSandboxImage({
40
+ id: "go",
41
+ image: "golang:1.23-alpine",
42
+ defaultEntrypoint: ["go", "run", "-"],
43
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0, timeoutMs: 2_000 },
44
+ description: "Go 1.23 alpine",
45
+ });
46
+ expect(entry.id).toBe("go");
47
+ expect(entry.image).toBe("golang:1.23-alpine");
48
+ expect(entry.healthcheck.command).toEqual(["go", "version"]);
49
+ expect(lookupSandboxImage("go").image).toBe("golang:1.23-alpine");
50
+ });
51
+
52
+ test("listSandboxImages returns sorted entries including bootstrap trio", () => {
53
+ registerSandboxImage({
54
+ id: "go",
55
+ image: "golang:1.23-alpine",
56
+ defaultEntrypoint: ["go", "run", "-"],
57
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
58
+ });
59
+ const ids = listSandboxImages().map((e) => e.id);
60
+ expect(ids).toEqual(["go", "javascript", "python", "shell"]);
61
+ });
62
+
63
+ test("listAllowedImageRefs flattens to image strings", () => {
64
+ const refs = listAllowedImageRefs();
65
+ expect(refs).toContain("python:3.13-slim");
66
+ expect(refs).toContain("node:22-alpine");
67
+ expect(refs).toContain("alpine:3.19");
68
+ });
69
+
70
+ test("lookupSandboxImage throws ImageNotFoundError for unknown id", () => {
71
+ expect(() => lookupSandboxImage("ghost")).toThrow(ImageNotFoundError);
72
+ expect(() => lookupSandboxImage("ghost")).toThrow(/known: /);
73
+ });
74
+ });
75
+
76
+ describe("registration validation (T8 surface)", () => {
77
+ beforeEach(() => {
78
+ _resetSandboxImageRegistry();
79
+ });
80
+ afterEach(() => {
81
+ _resetSandboxImageRegistry();
82
+ });
83
+
84
+ test("rejects duplicate registration", () => {
85
+ registerSandboxImage({
86
+ id: "go",
87
+ image: "golang:1.23-alpine",
88
+ defaultEntrypoint: ["go", "run", "-"],
89
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
90
+ });
91
+ expect(() =>
92
+ registerSandboxImage({
93
+ id: "go",
94
+ image: "golang:1.23-alpine",
95
+ defaultEntrypoint: ["go", "run", "-"],
96
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
97
+ }),
98
+ ).toThrow(/already registered/);
99
+ });
100
+
101
+ test("rejects re-registering the bootstrap trio", () => {
102
+ expect(() =>
103
+ registerSandboxImage({
104
+ id: "python",
105
+ image: "python:3.13-slim",
106
+ defaultEntrypoint: ["python3", "-c"],
107
+ healthcheck: { command: ["python3", "-c", "print(1)"], expectedExitCode: 0 },
108
+ }),
109
+ ).toThrow(/already registered/);
110
+ });
111
+
112
+ test("rejects image starting with dash (CLI flag injection)", () => {
113
+ expect(() =>
114
+ registerSandboxImage({
115
+ id: "evil",
116
+ image: "--privileged",
117
+ defaultEntrypoint: ["sh", "-c"],
118
+ healthcheck: { command: ["true"], expectedExitCode: 0 },
119
+ }),
120
+ ).toThrow(/CLI flag/);
121
+ });
122
+
123
+ test("rejects image with whitespace", () => {
124
+ expect(() =>
125
+ registerSandboxImage({
126
+ id: "evil",
127
+ image: "alpine:3.19 --privileged",
128
+ defaultEntrypoint: ["sh", "-c"],
129
+ healthcheck: { command: ["true"], expectedExitCode: 0 },
130
+ }),
131
+ ).toThrow(/whitespace/);
132
+ });
133
+
134
+ test("rejects image with newline", () => {
135
+ expect(() =>
136
+ registerSandboxImage({
137
+ id: "evil",
138
+ image: "alpine:3.19\n--privileged",
139
+ defaultEntrypoint: ["sh", "-c"],
140
+ healthcheck: { command: ["true"], expectedExitCode: 0 },
141
+ }),
142
+ ).toThrow(/whitespace/);
143
+ });
144
+
145
+ test("rejects image with shell-meta tag", () => {
146
+ expect(() =>
147
+ registerSandboxImage({
148
+ id: "evil",
149
+ image: "alpine:$(id)",
150
+ defaultEntrypoint: ["sh", "-c"],
151
+ healthcheck: { command: ["true"], expectedExitCode: 0 },
152
+ }),
153
+ ).toThrow(/valid registry reference/);
154
+ });
155
+
156
+ test("rejects empty argv healthcheck", () => {
157
+ expect(() =>
158
+ registerSandboxImage({
159
+ id: "go",
160
+ image: "golang:1.23-alpine",
161
+ defaultEntrypoint: ["go", "run", "-"],
162
+ healthcheck: { command: [], expectedExitCode: 0 },
163
+ }),
164
+ ).toThrow(/non-empty argv/);
165
+ });
166
+
167
+ test("rejects non-integer expected exit code", () => {
168
+ expect(() =>
169
+ registerSandboxImage({
170
+ id: "go",
171
+ image: "golang:1.23-alpine",
172
+ defaultEntrypoint: ["go", "run", "-"],
173
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0.5 },
174
+ }),
175
+ ).toThrow(/integer/);
176
+ });
177
+
178
+ test("rejects healthcheck argv with newline", () => {
179
+ expect(() =>
180
+ registerSandboxImage({
181
+ id: "go",
182
+ image: "golang:1.23-alpine",
183
+ defaultEntrypoint: ["go", "run", "-"],
184
+ healthcheck: { command: ["go\nversion"], expectedExitCode: 0 },
185
+ }),
186
+ ).toThrow(/newlines/);
187
+ });
188
+
189
+ test("rejects empty defaultEntrypoint", () => {
190
+ expect(() =>
191
+ registerSandboxImage({
192
+ id: "go",
193
+ image: "golang:1.23-alpine",
194
+ defaultEntrypoint: [],
195
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
196
+ }),
197
+ ).toThrow(/non-empty argv/);
198
+ });
199
+
200
+ test("rejects id with uppercase / spaces", () => {
201
+ expect(() =>
202
+ registerSandboxImage({
203
+ id: "GoLang",
204
+ image: "golang:1.23-alpine",
205
+ defaultEntrypoint: ["go", "run", "-"],
206
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
207
+ }),
208
+ ).toThrow(ImageRegistrationError);
209
+ expect(() =>
210
+ registerSandboxImage({
211
+ id: "go lang",
212
+ image: "golang:1.23-alpine",
213
+ defaultEntrypoint: ["go", "run", "-"],
214
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
215
+ }),
216
+ ).toThrow(ImageRegistrationError);
217
+ });
218
+
219
+ test("accepts digest-pinned image", () => {
220
+ const digest = `a${"b".repeat(63)}`;
221
+ registerSandboxImage({
222
+ id: "rust-pinned",
223
+ image: `rust:1-alpine@sha256:${digest}`,
224
+ defaultEntrypoint: ["sh", "-c"],
225
+ healthcheck: { command: ["rustc", "--version"], expectedExitCode: 0 },
226
+ });
227
+ expect(lookupSandboxImage("rust-pinned").image).toBe(`rust:1-alpine@sha256:${digest}`);
228
+ });
229
+ });
230
+
231
+ describe("healthcheck status tracking", () => {
232
+ beforeEach(() => {
233
+ _resetSandboxImageRegistry();
234
+ });
235
+ afterEach(() => {
236
+ _resetSandboxImageRegistry();
237
+ });
238
+
239
+ test("snapshotImageStatuses lists every registered image", () => {
240
+ registerSandboxImage({
241
+ id: "go",
242
+ image: "golang:1.23-alpine",
243
+ defaultEntrypoint: ["go", "run", "-"],
244
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
245
+ });
246
+ const statuses = snapshotImageStatuses();
247
+ const ids = statuses.map((s) => s.id);
248
+ expect(ids).toContain("go");
249
+ expect(ids).toContain("python");
250
+ for (const s of statuses) {
251
+ expect(s.healthy).toBe(false);
252
+ expect(s.lastHealthyAt).toBeNull();
253
+ expect(s.lastError).toBeNull();
254
+ }
255
+ });
256
+
257
+ test("markHealthy / markUnhealthy update status", () => {
258
+ registerSandboxImage({
259
+ id: "go",
260
+ image: "golang:1.23-alpine",
261
+ defaultEntrypoint: ["go", "run", "-"],
262
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
263
+ });
264
+ markHealthy("go", 1700000000000);
265
+ let s = snapshotImageStatuses().find((x) => x.id === "go");
266
+ expect(s).toBeDefined();
267
+ expect(s?.healthy).toBe(true);
268
+ expect(s?.lastHealthyAt).toBe(new Date(1700000000000).toISOString());
269
+ markUnhealthy("go", "image pull denied");
270
+ s = snapshotImageStatuses().find((x) => x.id === "go");
271
+ expect(s?.healthy).toBe(false);
272
+ expect(s?.lastError).toBe("image pull denied");
273
+ });
274
+
275
+ test("markHealthy throws for unregistered id", () => {
276
+ expect(() => markHealthy("ghost")).toThrow(ImageNotFoundError);
277
+ expect(() => markUnhealthy("ghost", "nope")).toThrow(ImageNotFoundError);
278
+ });
279
+
280
+ test("runHealthchecks runs each entry through the supplied probe", async () => {
281
+ registerSandboxImage({
282
+ id: "go",
283
+ image: "golang:1.23-alpine",
284
+ defaultEntrypoint: ["go", "run", "-"],
285
+ healthcheck: { command: ["go", "version"], expectedExitCode: 0 },
286
+ });
287
+ const calls: string[] = [];
288
+ const statuses = await runHealthchecks(async (entry) => {
289
+ calls.push(entry.id);
290
+ // Python + go succeed; javascript fails (exit 17); shell throws.
291
+ if (entry.id === "javascript") return { exitCode: 17, stderr: "node: not found" };
292
+ if (entry.id === "shell") throw new Error("shell probe blew up");
293
+ return { exitCode: 0, stderr: "" };
294
+ });
295
+ expect(calls.sort()).toEqual(["go", "javascript", "python", "shell"]);
296
+ const byId = new Map(statuses.map((s) => [s.id, s]));
297
+ expect(byId.get("go")?.healthy).toBe(true);
298
+ expect(byId.get("python")?.healthy).toBe(true);
299
+ expect(byId.get("javascript")?.healthy).toBe(false);
300
+ expect(byId.get("javascript")?.lastError).toContain("node: not found");
301
+ expect(byId.get("shell")?.healthy).toBe(false);
302
+ expect(byId.get("shell")?.lastError).toContain("shell probe blew up");
303
+ });
304
+ });
package/src/index.ts ADDED
@@ -0,0 +1,356 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import { SANDBOX_DEFAULT_ALLOWED_IMAGES } from "@crewhaus/sandbox";
3
+
4
+ /**
5
+ * Catalog R8 `sandbox-image-registry` — runtime allow-list registry.
6
+ *
7
+ * Section 36 prereq for the polyglot sandbox images. Replaces the
8
+ * hardcoded `CREWHAUS_SANDBOX_ALLOWED_IMAGES` env var with a runtime
9
+ * registry pattern: callers `registerSandboxImage({ id, image,
10
+ * healthcheck, defaultEntrypoint })` and the rest of the system reads
11
+ * via `lookupSandboxImage(id)` / `listSandboxImages()`.
12
+ *
13
+ * The §18 trio (python / node / alpine) auto-registers at first
14
+ * registry access for backwards compat — existing callers that depend
15
+ * on `python:3.13-slim` etc. continue to work without code changes.
16
+ *
17
+ * The healthcheck contract is `{ command: string[], expectedExitCode,
18
+ * timeoutMs }`. Sandbox boot waits for a healthcheck pass before
19
+ * allowing the first `exec()`. Runtimes that don't run healthchecks
20
+ * (the `noop` backend or pure unit tests) can call `markHealthy(id)`
21
+ * to bypass.
22
+ *
23
+ * SECURITY: registration validates the same image-string grammar that
24
+ * `@crewhaus/sandbox` uses (no leading dash for CLI flag injection,
25
+ * no whitespace for newline injection, registry-reference shape).
26
+ * Re-registering an id is rejected — `ImageRegistrationError`. The
27
+ * registry is module-level singleton state; tests use `_resetSandboxImageRegistry()`.
28
+ *
29
+ * Layer R8.
30
+ */
31
+
32
+ export type SandboxImageHealthcheck = {
33
+ /** Argv passed to the container; e.g. ["go", "version"]. */
34
+ readonly command: ReadonlyArray<string>;
35
+ /** Expected exit code (typically 0). */
36
+ readonly expectedExitCode: number;
37
+ /** Per-call timeout. Defaults to 5_000 if omitted by callers. */
38
+ readonly timeoutMs?: number;
39
+ };
40
+
41
+ export type SandboxImageEntry = {
42
+ /** Stable opaque id used at lookup, e.g. "go", "python". */
43
+ readonly id: string;
44
+ /** Container image reference, e.g. "golang:1.23-alpine". */
45
+ readonly image: string;
46
+ /** Healthcheck contract; runs before the first exec(). */
47
+ readonly healthcheck: SandboxImageHealthcheck;
48
+ /** Default entrypoint argv for snippet-mode callers, e.g. ["go", "run", "-"]. */
49
+ readonly defaultEntrypoint: ReadonlyArray<string>;
50
+ /** Optional human-readable description shown by `crewhaus sandbox doctor`. */
51
+ readonly description?: string;
52
+ };
53
+
54
+ export type SandboxImageRegistration = {
55
+ readonly id: string;
56
+ readonly image: string;
57
+ readonly healthcheck: SandboxImageHealthcheck;
58
+ readonly defaultEntrypoint: ReadonlyArray<string>;
59
+ readonly description?: string;
60
+ };
61
+
62
+ export type SandboxImageStatus = {
63
+ readonly id: string;
64
+ readonly image: string;
65
+ readonly healthy: boolean;
66
+ /** ISO timestamp of last successful healthcheck, or null. */
67
+ readonly lastHealthyAt: string | null;
68
+ /** Last failure detail (image pull failure, exec fail, etc.). */
69
+ readonly lastError: string | null;
70
+ };
71
+
72
+ export class ImageRegistrationError extends CrewhausError {
73
+ override readonly name = "ImageRegistrationError";
74
+ constructor(message: string, cause?: unknown) {
75
+ super("config", message, cause);
76
+ }
77
+ }
78
+
79
+ export class ImageNotFoundError extends CrewhausError {
80
+ override readonly name = "ImageNotFoundError";
81
+ constructor(message: string, cause?: unknown) {
82
+ super("config", message, cause);
83
+ }
84
+ }
85
+
86
+ const IMAGE_RE = /^[a-z0-9][a-z0-9._\-/]*(?::[a-zA-Z0-9._\-]+)?(?:@sha256:[a-f0-9]{64})?$/;
87
+ const ID_RE = /^[a-z][a-z0-9_-]*$/;
88
+
89
+ type RegistryRecord = {
90
+ entry: SandboxImageEntry;
91
+ healthy: boolean;
92
+ lastHealthyAt: number | null;
93
+ lastError: string | null;
94
+ };
95
+
96
+ const registry: Map<string, RegistryRecord> = new Map();
97
+ let bootstrapped = false;
98
+
99
+ function validateImage(image: string): void {
100
+ if (image.length === 0) throw new ImageRegistrationError("image is required");
101
+ if (image.startsWith("-")) {
102
+ throw new ImageRegistrationError(`image "${image}" looks like a CLI flag — refused`);
103
+ }
104
+ if (image.includes("\n") || /\s/.test(image)) {
105
+ throw new ImageRegistrationError(`image "${image}" contains whitespace — refused`);
106
+ }
107
+ if (!IMAGE_RE.test(image)) {
108
+ throw new ImageRegistrationError(`image "${image}" is not a valid registry reference`);
109
+ }
110
+ }
111
+
112
+ function validateId(id: string): void {
113
+ if (id.length === 0) throw new ImageRegistrationError("id is required");
114
+ if (!ID_RE.test(id)) {
115
+ throw new ImageRegistrationError(
116
+ `id "${id}" must match /^[a-z][a-z0-9_-]*$/ (lowercase, no spaces)`,
117
+ );
118
+ }
119
+ }
120
+
121
+ function validateHealthcheck(hc: SandboxImageHealthcheck): void {
122
+ if (!Array.isArray(hc.command) || hc.command.length === 0) {
123
+ throw new ImageRegistrationError("healthcheck.command must be a non-empty argv array");
124
+ }
125
+ for (const arg of hc.command) {
126
+ if (typeof arg !== "string") {
127
+ throw new ImageRegistrationError("healthcheck.command argv entries must be strings");
128
+ }
129
+ if (arg.includes("\n")) {
130
+ throw new ImageRegistrationError("healthcheck.command argv entries may not contain newlines");
131
+ }
132
+ }
133
+ if (!Number.isInteger(hc.expectedExitCode)) {
134
+ throw new ImageRegistrationError("healthcheck.expectedExitCode must be an integer");
135
+ }
136
+ if (hc.timeoutMs !== undefined) {
137
+ if (!Number.isFinite(hc.timeoutMs) || hc.timeoutMs <= 0) {
138
+ throw new ImageRegistrationError("healthcheck.timeoutMs must be a positive number");
139
+ }
140
+ }
141
+ }
142
+
143
+ function validateEntrypoint(argv: ReadonlyArray<string>): void {
144
+ if (!Array.isArray(argv) || argv.length === 0) {
145
+ throw new ImageRegistrationError("defaultEntrypoint must be a non-empty argv array");
146
+ }
147
+ for (const arg of argv) {
148
+ if (typeof arg !== "string") {
149
+ throw new ImageRegistrationError("defaultEntrypoint argv entries must be strings");
150
+ }
151
+ if (arg.includes("\n")) {
152
+ throw new ImageRegistrationError("defaultEntrypoint argv entries may not contain newlines");
153
+ }
154
+ }
155
+ }
156
+
157
+ function bootstrapDefaults(): void {
158
+ if (bootstrapped) return;
159
+ bootstrapped = true;
160
+ // Register the §18 trio so the existing tool-code-execution paths
161
+ // continue to work unchanged. We swallow duplicate-registration
162
+ // errors so deliberate test resets don't cascade.
163
+ const trio: ReadonlyArray<SandboxImageRegistration> = [
164
+ {
165
+ id: "python",
166
+ image: "python:3.13-slim",
167
+ defaultEntrypoint: ["python3", "-c"],
168
+ healthcheck: {
169
+ command: ["python3", "-c", "print('ok')"],
170
+ expectedExitCode: 0,
171
+ timeoutMs: 4_000,
172
+ },
173
+ description: "Python 3.13 slim — §18 default for the Python tool.",
174
+ },
175
+ {
176
+ id: "javascript",
177
+ image: "node:22-alpine",
178
+ defaultEntrypoint: ["node", "-e"],
179
+ healthcheck: {
180
+ command: ["node", "-e", "console.log('ok')"],
181
+ expectedExitCode: 0,
182
+ timeoutMs: 4_000,
183
+ },
184
+ description: "Node 22 alpine — §18 default for the JavaScript tool.",
185
+ },
186
+ {
187
+ id: "shell",
188
+ image: "alpine:3.19",
189
+ defaultEntrypoint: ["sh", "-c"],
190
+ healthcheck: {
191
+ command: ["sh", "-c", "echo ok"],
192
+ expectedExitCode: 0,
193
+ timeoutMs: 500,
194
+ },
195
+ description: "Alpine 3.19 — §18 default for the Shell tool.",
196
+ },
197
+ ];
198
+ for (const reg of trio) {
199
+ if (!registry.has(reg.id)) registerInternal(reg, { allowOverride: false });
200
+ }
201
+ }
202
+
203
+ function registerInternal(
204
+ reg: SandboxImageRegistration,
205
+ opts: { allowOverride: boolean },
206
+ ): SandboxImageEntry {
207
+ validateId(reg.id);
208
+ validateImage(reg.image);
209
+ validateHealthcheck(reg.healthcheck);
210
+ validateEntrypoint(reg.defaultEntrypoint);
211
+ if (registry.has(reg.id) && !opts.allowOverride) {
212
+ throw new ImageRegistrationError(
213
+ `sandbox image id "${reg.id}" is already registered — call _resetSandboxImageRegistry() in tests or pick a different id`,
214
+ );
215
+ }
216
+ const entry: SandboxImageEntry = {
217
+ id: reg.id,
218
+ image: reg.image,
219
+ healthcheck: {
220
+ command: [...reg.healthcheck.command],
221
+ expectedExitCode: reg.healthcheck.expectedExitCode,
222
+ ...(reg.healthcheck.timeoutMs !== undefined ? { timeoutMs: reg.healthcheck.timeoutMs } : {}),
223
+ },
224
+ defaultEntrypoint: [...reg.defaultEntrypoint],
225
+ ...(reg.description !== undefined ? { description: reg.description } : {}),
226
+ };
227
+ registry.set(reg.id, {
228
+ entry,
229
+ healthy: false,
230
+ lastHealthyAt: null,
231
+ lastError: null,
232
+ });
233
+ return entry;
234
+ }
235
+
236
+ /**
237
+ * Register a sandbox image. Throws `ImageRegistrationError` if the id
238
+ * is already registered or if any field violates the validation rules.
239
+ */
240
+ export function registerSandboxImage(reg: SandboxImageRegistration): SandboxImageEntry {
241
+ bootstrapDefaults();
242
+ return registerInternal(reg, { allowOverride: false });
243
+ }
244
+
245
+ /**
246
+ * Look up a registered image by id. Throws `ImageNotFoundError` if the
247
+ * id is not in the registry.
248
+ */
249
+ export function lookupSandboxImage(id: string): SandboxImageEntry {
250
+ bootstrapDefaults();
251
+ const rec = registry.get(id);
252
+ if (rec === undefined) {
253
+ const known = [...registry.keys()].sort().join(", ") || "(empty)";
254
+ throw new ImageNotFoundError(`sandbox image "${id}" is not registered — known: ${known}`);
255
+ }
256
+ return rec.entry;
257
+ }
258
+
259
+ /** Returns true if the image is registered. */
260
+ export function hasSandboxImage(id: string): boolean {
261
+ bootstrapDefaults();
262
+ return registry.has(id);
263
+ }
264
+
265
+ /** List all registered images, sorted by id. */
266
+ export function listSandboxImages(): ReadonlyArray<SandboxImageEntry> {
267
+ bootstrapDefaults();
268
+ return [...registry.values()].map((r) => r.entry).sort((a, b) => a.id.localeCompare(b.id));
269
+ }
270
+
271
+ /** List images flattened to their image-reference strings (for sandbox `allowedImages`). */
272
+ export function listAllowedImageRefs(): ReadonlyArray<string> {
273
+ return listSandboxImages().map((e) => e.image);
274
+ }
275
+
276
+ /**
277
+ * Mark an image healthy without invoking docker. Used by the noop
278
+ * backend, smoke tests, and `crewhaus sandbox doctor` after a
279
+ * successful out-of-band probe.
280
+ */
281
+ export function markHealthy(id: string, when: number = Date.now()): void {
282
+ bootstrapDefaults();
283
+ const rec = registry.get(id);
284
+ if (rec === undefined) {
285
+ throw new ImageNotFoundError(`sandbox image "${id}" is not registered`);
286
+ }
287
+ rec.healthy = true;
288
+ rec.lastHealthyAt = when;
289
+ rec.lastError = null;
290
+ }
291
+
292
+ /** Mark an image unhealthy with a human-readable error. */
293
+ export function markUnhealthy(id: string, error: string): void {
294
+ bootstrapDefaults();
295
+ const rec = registry.get(id);
296
+ if (rec === undefined) {
297
+ throw new ImageNotFoundError(`sandbox image "${id}" is not registered`);
298
+ }
299
+ rec.healthy = false;
300
+ rec.lastError = error;
301
+ }
302
+
303
+ /** Snapshot of every registered image's healthcheck status. */
304
+ export function snapshotImageStatuses(): ReadonlyArray<SandboxImageStatus> {
305
+ bootstrapDefaults();
306
+ return [...registry.values()]
307
+ .map((rec) => ({
308
+ id: rec.entry.id,
309
+ image: rec.entry.image,
310
+ healthy: rec.healthy,
311
+ lastHealthyAt: rec.lastHealthyAt === null ? null : new Date(rec.lastHealthyAt).toISOString(),
312
+ lastError: rec.lastError,
313
+ }))
314
+ .sort((a, b) => a.id.localeCompare(b.id));
315
+ }
316
+
317
+ /**
318
+ * Probe runner: execs each registered image's healthcheck via the
319
+ * caller-supplied probe function and updates internal status. The
320
+ * probe function abstracts docker/podman/noop so this package never
321
+ * spawns processes itself — keeping the registry pure.
322
+ */
323
+ export async function runHealthchecks(
324
+ probe: (entry: SandboxImageEntry) => Promise<{ exitCode: number; stderr: string }>,
325
+ ): Promise<ReadonlyArray<SandboxImageStatus>> {
326
+ bootstrapDefaults();
327
+ for (const rec of registry.values()) {
328
+ try {
329
+ const result = await probe(rec.entry);
330
+ if (result.exitCode === rec.entry.healthcheck.expectedExitCode) {
331
+ rec.healthy = true;
332
+ rec.lastHealthyAt = Date.now();
333
+ rec.lastError = null;
334
+ } else {
335
+ rec.healthy = false;
336
+ rec.lastError = `healthcheck exit ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`;
337
+ }
338
+ } catch (err) {
339
+ rec.healthy = false;
340
+ rec.lastError = err instanceof Error ? err.message : String(err);
341
+ }
342
+ }
343
+ return snapshotImageStatuses();
344
+ }
345
+
346
+ /**
347
+ * Test-only — clears the registry. Bootstrapped defaults will be
348
+ * re-registered on the next public-API call.
349
+ */
350
+ export function _resetSandboxImageRegistry(): void {
351
+ registry.clear();
352
+ bootstrapped = false;
353
+ }
354
+
355
+ /** Re-export for callers configuring a sandbox from registry contents. */
356
+ export { SANDBOX_DEFAULT_ALLOWED_IMAGES };