@omg-dev/sandbox 0.4.25
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/README.md +84 -0
- package/dist/index.mjs +2 -0
- package/dist/templates-CdQWcmOC.mjs +251 -0
- package/dist/templates.mjs +2 -0
- package/package.json +37 -0
- package/src/client.test.ts +53 -0
- package/src/client.ts +145 -0
- package/src/index.ts +2 -0
- package/src/templates.test.ts +41 -0
- package/src/templates.ts +189 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# `@omg-dev/sandbox`
|
|
2
|
+
|
|
3
|
+
Server-only control-plane client and TypeScript template baker for Vibes
|
|
4
|
+
sandboxes. This is deliberately separate from `@omg-dev/sdk/sandbox`, which is
|
|
5
|
+
the restricted router available to code running *inside* a user sandbox.
|
|
6
|
+
|
|
7
|
+
## Public usage (API key)
|
|
8
|
+
|
|
9
|
+
Get an API key (`omg_sk_…`) from the omg.dev dashboard and point the client at
|
|
10
|
+
the public API — see [docs.omg.dev/sandbox](https://docs.omg.dev/sandbox):
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { SandboxClient } from "@omg-dev/sandbox";
|
|
14
|
+
|
|
15
|
+
const client = new SandboxClient({
|
|
16
|
+
baseUrl: "https://infra.omg.dev",
|
|
17
|
+
token: process.env.OMG_API_KEY!, // omg_sk_...
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const sandbox = await client.create({ templateId: "react-ts", ports: [5173] });
|
|
21
|
+
await sandbox.exec("echo", ["hello from a microVM"]);
|
|
22
|
+
await sandbox.snapshot();
|
|
23
|
+
await sandbox.stop();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Template baking (service / self-host)
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import {
|
|
30
|
+
SandboxClient,
|
|
31
|
+
apt,
|
|
32
|
+
bakeTemplate,
|
|
33
|
+
check,
|
|
34
|
+
defineTemplate,
|
|
35
|
+
run,
|
|
36
|
+
} from "@omg-dev/sandbox";
|
|
37
|
+
|
|
38
|
+
const template = defineTemplate({
|
|
39
|
+
id: "my-agent",
|
|
40
|
+
version: "1",
|
|
41
|
+
title: "My agent",
|
|
42
|
+
ports: [3000],
|
|
43
|
+
install: [
|
|
44
|
+
apt.packages(["tmux"]),
|
|
45
|
+
run({ user: "user", command: "bun install -g my-agent@1.2.3" }),
|
|
46
|
+
],
|
|
47
|
+
checks: [check.command("tmux"), check.command("my-agent", { user: "user" })],
|
|
48
|
+
start: {
|
|
49
|
+
user: "user",
|
|
50
|
+
command: "my-agent serve --host 0.0.0.0 --port 3000",
|
|
51
|
+
readiness: { port: 3000 },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const client = new SandboxClient({
|
|
56
|
+
baseUrl: process.env.VIBES_SANDBOX_URL!,
|
|
57
|
+
token: process.env.VIBES_INFRA_SERVICE_TOKEN!,
|
|
58
|
+
ownerId: "user-or-org-id",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await bakeTemplate(client, template);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`bakeTemplate` creates a raw sandbox, applies each typed step, verifies checks,
|
|
65
|
+
writes a generic boot contract, snapshots it, waits for durable upload, and
|
|
66
|
+
publishes both `my-agent-v1` and `my-agent` (latest). Direct service clients
|
|
67
|
+
publish an immutable version in the global system catalogue; clients with an
|
|
68
|
+
`ownerId` publish both names in that user's private namespace. A deployment
|
|
69
|
+
consumes either with `{ templateId: "my-agent-v1" }` or `{ templateId:
|
|
70
|
+
"my-agent" }`.
|
|
71
|
+
|
|
72
|
+
Publication also records the compiled start command, ports, and readiness probe
|
|
73
|
+
in the template registry. Creation copies that executable contract onto the
|
|
74
|
+
sandbox row, so hibernate/resume never re-reads mutable registry state and a
|
|
75
|
+
later `latest` publish cannot change an existing sandbox.
|
|
76
|
+
|
|
77
|
+
Repository templates live at `templates/<id>/template.ts`. The existing Build
|
|
78
|
+
Templates workflow discovers them and uses the same baker. `agent-catalog.ts`
|
|
79
|
+
is the source of truth for the first-party coding-agent images.
|
|
80
|
+
|
|
81
|
+
Never import this package in browser code: its client accepts privileged API
|
|
82
|
+
credentials. User-authenticated server routes may instantiate it with the
|
|
83
|
+
caller's token and owner ID; install commands still run only inside the
|
|
84
|
+
Firecracker guest.
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +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 };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/client.ts
|
|
3
|
+
const TEMPLATE_ID = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
|
4
|
+
function assertTemplateId(id) {
|
|
5
|
+
const normalized = id.trim().toLowerCase();
|
|
6
|
+
if (!TEMPLATE_ID.test(normalized)) throw new Error(`invalid template id ${JSON.stringify(id)} (want 2-40 lowercase letters, digits, or hyphens)`);
|
|
7
|
+
return normalized;
|
|
8
|
+
}
|
|
9
|
+
var SandboxClient = class {
|
|
10
|
+
baseUrl;
|
|
11
|
+
ownerId;
|
|
12
|
+
token;
|
|
13
|
+
fetchImpl;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
16
|
+
this.token = options.token;
|
|
17
|
+
this.ownerId = options.ownerId;
|
|
18
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
19
|
+
if (!this.baseUrl) throw new Error("sandbox baseUrl is required");
|
|
20
|
+
if (!this.token) throw new Error("sandbox token is required");
|
|
21
|
+
}
|
|
22
|
+
async request(method, path, body) {
|
|
23
|
+
const headers = {
|
|
24
|
+
authorization: `Bearer ${this.token}`,
|
|
25
|
+
"content-type": "application/json"
|
|
26
|
+
};
|
|
27
|
+
if (this.ownerId) headers["x-on-behalf-of"] = this.ownerId;
|
|
28
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers,
|
|
31
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
32
|
+
});
|
|
33
|
+
if (response.status === 204) return void 0;
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
let parsed = text;
|
|
36
|
+
try {
|
|
37
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
38
|
+
} catch {}
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
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}`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
async create(options = {}) {
|
|
46
|
+
const meta = await this.request("POST", "/v1/sandboxes", options);
|
|
47
|
+
return this.wrap(meta.id);
|
|
48
|
+
}
|
|
49
|
+
async getSnapshot(id) {
|
|
50
|
+
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
51
|
+
}
|
|
52
|
+
async publishTemplate(templateId, snapshotId, runtime) {
|
|
53
|
+
const id = assertTemplateId(templateId);
|
|
54
|
+
await this.request("POST", `/v1/templates/${id}/latest`, {
|
|
55
|
+
snapshotId,
|
|
56
|
+
...runtime
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async resolveTemplate(templateId) {
|
|
60
|
+
const id = assertTemplateId(templateId);
|
|
61
|
+
return this.request("GET", `/v1/templates/${id}/latest`);
|
|
62
|
+
}
|
|
63
|
+
wrap(id) {
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
exec: (command, args = [], options = {}) => this.request("POST", `/v1/sandboxes/${id}/exec`, {
|
|
67
|
+
cmd: command,
|
|
68
|
+
args,
|
|
69
|
+
...options
|
|
70
|
+
}),
|
|
71
|
+
shell: (script, options = {}) => this.request("POST", `/v1/sandboxes/${id}/exec`, {
|
|
72
|
+
cmd: "/bin/bash",
|
|
73
|
+
args: ["-lc", script],
|
|
74
|
+
...options
|
|
75
|
+
}),
|
|
76
|
+
writeFiles: async (files) => {
|
|
77
|
+
await this.request("POST", `/v1/sandboxes/${id}/files`, files.map((file) => ({
|
|
78
|
+
path: file.path,
|
|
79
|
+
content: Buffer.from(file.content).toString("base64"),
|
|
80
|
+
mode: file.mode
|
|
81
|
+
})));
|
|
82
|
+
},
|
|
83
|
+
snapshot: () => this.request("POST", `/v1/sandboxes/${id}/snapshot`),
|
|
84
|
+
stop: () => this.request("DELETE", `/v1/sandboxes/${id}`)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/templates.ts
|
|
90
|
+
const apt = { packages(packages) {
|
|
91
|
+
if (!packages.length) throw new Error("apt.packages requires at least one package");
|
|
92
|
+
for (const name of packages) if (!/^[a-z0-9][a-z0-9+.-]*$/.test(name)) throw new Error(`invalid apt package ${name}`);
|
|
93
|
+
return {
|
|
94
|
+
kind: "apt",
|
|
95
|
+
packages: [...packages]
|
|
96
|
+
};
|
|
97
|
+
} };
|
|
98
|
+
const download = { archive(options) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "archive",
|
|
101
|
+
...options
|
|
102
|
+
};
|
|
103
|
+
} };
|
|
104
|
+
function run(options) {
|
|
105
|
+
return {
|
|
106
|
+
kind: "run",
|
|
107
|
+
...options
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const check = {
|
|
111
|
+
command(command, options = {}) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "command",
|
|
114
|
+
command,
|
|
115
|
+
...options
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
file(path) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "file",
|
|
121
|
+
path
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function defineTemplate(definition) {
|
|
126
|
+
assertTemplateId(definition.id);
|
|
127
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,19}$/.test(definition.version)) throw new Error("invalid template version");
|
|
128
|
+
if (!definition.title.trim()) throw new Error("template title is required");
|
|
129
|
+
for (const port of definition.ports ?? []) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid template port ${port}`);
|
|
130
|
+
return Object.freeze(definition);
|
|
131
|
+
}
|
|
132
|
+
function templateVersionRef(definition) {
|
|
133
|
+
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
134
|
+
const maxBase = 40 - suffix.length;
|
|
135
|
+
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}`);
|
|
137
|
+
}
|
|
138
|
+
function templateDefinitionHash(definition) {
|
|
139
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex");
|
|
140
|
+
}
|
|
141
|
+
function templateRuntimeContract(definition, immutableRef) {
|
|
142
|
+
return {
|
|
143
|
+
immutableRef,
|
|
144
|
+
startCommand: definition.start ? "exec /home/user/.omg/template/bootstrap.sh" : void 0,
|
|
145
|
+
readinessPort: definition.start?.readiness?.port,
|
|
146
|
+
readinessPath: definition.start?.readiness?.path,
|
|
147
|
+
ports: [...definition.ports ?? []]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function quote(value) {
|
|
151
|
+
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
|
152
|
+
}
|
|
153
|
+
function commandForUser(command, user) {
|
|
154
|
+
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
155
|
+
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)}`;
|
|
157
|
+
}
|
|
158
|
+
function shellEnvAssignments(env) {
|
|
159
|
+
return Object.entries(env).map(([key, value]) => {
|
|
160
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable ${JSON.stringify(key)}`);
|
|
161
|
+
return `${key}=${quote(value)}`;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function compileStep(step) {
|
|
165
|
+
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
|
+
if (step.kind === "archive") {
|
|
167
|
+
const strip = step.stripComponents ?? 1;
|
|
168
|
+
if (!Number.isInteger(strip) || strip < 0 || strip > 20) throw new Error("invalid stripComponents");
|
|
169
|
+
return { script: `set -euo pipefail\ntmp=$(mktemp /tmp/template-archive-XXXXXX)\ntrap 'rm -f \"$tmp\"' EXIT\ncurl -fSL --retry 3 --retry-delay 2 ${quote(step.url)} -o \"$tmp\"\nmkdir -p ${quote(step.destination)}\nTAR_OPTIONS= tar -xzf \"$tmp\" -C ${quote(step.destination)} --strip-components=${strip} --overwrite --no-same-owner --no-same-permissions --touch` };
|
|
170
|
+
}
|
|
171
|
+
const env = shellEnvAssignments(step.env ?? {}).join(" ");
|
|
172
|
+
return {
|
|
173
|
+
script: `set -euo pipefail\n${commandForUser(`${step.cwd ? `cd ${quote(step.cwd)} && ` : ""}${env ? `export ${env} && ` : ""}${step.command}`, step.user)}`,
|
|
174
|
+
timeoutMs: step.timeoutMs
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function compileStartScript(start) {
|
|
178
|
+
const port = start.readiness?.port;
|
|
179
|
+
const timeout = start.readiness?.timeoutSeconds ?? 60;
|
|
180
|
+
const foreground = commandForUser(`${shellEnvAssignments({
|
|
181
|
+
HOME: start.user ? `/home/${start.user}` : "/root",
|
|
182
|
+
...start.env ?? {}
|
|
183
|
+
}).map((assignment) => `export ${assignment}`).join("\n")}\n${`${start.cwd ? `cd ${quote(start.cwd)} && ` : ""}${start.command}`}`, start.user);
|
|
184
|
+
const probe = port ? `for _ in $(seq 1 ${timeout}); do curl -fsS -m1 -o /dev/null ${quote(`http://127.0.0.1:${port}${start.readiness?.path ?? "/"}`)} && exit 0; sleep 1; done\necho 'template service did not become ready on :${port}' >&2\nexit 1` : "exit 0";
|
|
185
|
+
return `#!/usr/bin/env bash\nset -euo pipefail\nmkdir -p /home/user/.omg/template\npidfile=/home/user/.omg/template/start.pid\nif [ -s \"$pidfile\" ] && kill -0 \"$(cat \"$pidfile\")\" 2>/dev/null; then exit 0; fi\nnohup /bin/bash -lc ${quote(`while true; do ${foreground} >>/home/user/.omg/template/start.log 2>&1 || true; sleep 2; done`)} >/dev/null 2>&1 &\necho $! > \"$pidfile\"\n${probe}\n`;
|
|
186
|
+
}
|
|
187
|
+
async function assertExec(label, result) {
|
|
188
|
+
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
189
|
+
}
|
|
190
|
+
async function applyTemplate(sandbox, definition, onLog = () => {}) {
|
|
191
|
+
for (const [index, step] of definition.install.entries()) {
|
|
192
|
+
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
193
|
+
const compiled = compileStep(step);
|
|
194
|
+
await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 6e4 }));
|
|
195
|
+
}
|
|
196
|
+
if (definition.start) await sandbox.writeFiles([{
|
|
197
|
+
path: "/home/user/.omg/template/bootstrap.sh",
|
|
198
|
+
content: compileStartScript(definition.start),
|
|
199
|
+
mode: 493
|
|
200
|
+
}]);
|
|
201
|
+
const manifest = {
|
|
202
|
+
...definition,
|
|
203
|
+
definitionHash: templateDefinitionHash(definition)
|
|
204
|
+
};
|
|
205
|
+
await sandbox.writeFiles([{
|
|
206
|
+
path: "/home/user/.omg/template/definition.json",
|
|
207
|
+
content: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
208
|
+
mode: 420
|
|
209
|
+
}]);
|
|
210
|
+
for (const item of definition.checks ?? []) {
|
|
211
|
+
const test = item.kind === "file" ? `test -e ${quote(item.path)}` : commandForUser(`command -v ${quote(item.command)}`, item.user);
|
|
212
|
+
await assertExec(`template check ${item.kind === "file" ? item.path : item.command}`, await sandbox.shell(`set -euo pipefail\n${test}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function waitForSnapshotUpload(client, snapshot, timeoutMs = 10 * 6e4) {
|
|
216
|
+
const deadline = Date.now() + timeoutMs;
|
|
217
|
+
while (Date.now() < deadline) {
|
|
218
|
+
if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
222
|
+
}
|
|
223
|
+
async function bakeTemplate(client, definition, options = {}) {
|
|
224
|
+
const log = options.onLog ?? (() => {});
|
|
225
|
+
const versionRef = templateVersionRef(definition);
|
|
226
|
+
const sandbox = await client.create({
|
|
227
|
+
ports: [...definition.ports ?? []],
|
|
228
|
+
skipAppProcesses: true,
|
|
229
|
+
projectSlug: definition.id
|
|
230
|
+
});
|
|
231
|
+
let snapshotted = false;
|
|
232
|
+
try {
|
|
233
|
+
await applyTemplate(sandbox, definition, log);
|
|
234
|
+
const snapshot = await sandbox.snapshot();
|
|
235
|
+
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);
|
|
240
|
+
return {
|
|
241
|
+
templateId: definition.id,
|
|
242
|
+
versionRef,
|
|
243
|
+
definitionHash: templateDefinitionHash(definition),
|
|
244
|
+
snapshotId: snapshot.id
|
|
245
|
+
};
|
|
246
|
+
} finally {
|
|
247
|
+
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
//#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 };
|
|
@@ -0,0 +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 };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omg-dev/sandbox",
|
|
3
|
+
"version": "0.4.25",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"./templates": {
|
|
11
|
+
"types": "./src/templates.ts",
|
|
12
|
+
"default": "./dist/templates.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "^1.0.0",
|
|
21
|
+
"typescript": "npm:@typescript/typescript6@6.0.2"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/BennyKok/vibes.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://docs.omg.dev",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SandboxClient } from "./client";
|
|
3
|
+
import { bakeTemplate, defineTemplate } from "./templates";
|
|
4
|
+
|
|
5
|
+
describe("SandboxClient template bake", () => {
|
|
6
|
+
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;
|
|
20
|
+
const client = new SandboxClient({ baseUrl: "https://infra.example", token: "secret", ownerId: "user-123", fetch: fakeFetch });
|
|
21
|
+
const result = await bakeTemplate(client, defineTemplate({
|
|
22
|
+
id: "my-agent",
|
|
23
|
+
version: "3",
|
|
24
|
+
title: "Mine",
|
|
25
|
+
ports: [8766],
|
|
26
|
+
install: [],
|
|
27
|
+
start: { command: "bun start", readiness: { port: 8766, path: "/health" } },
|
|
28
|
+
}));
|
|
29
|
+
expect(result.versionRef).toBe("my-agent-v3");
|
|
30
|
+
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",
|
|
33
|
+
"/v1/templates/my-agent/latest",
|
|
34
|
+
]);
|
|
35
|
+
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([
|
|
37
|
+
{
|
|
38
|
+
snapshotId: "snap-1",
|
|
39
|
+
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
40
|
+
readinessPort: 8766,
|
|
41
|
+
readinessPath: "/health",
|
|
42
|
+
ports: [8766],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
snapshotId: "snap-1",
|
|
46
|
+
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
47
|
+
readinessPort: 8766,
|
|
48
|
+
readinessPath: "/health",
|
|
49
|
+
ports: [8766],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export interface SandboxClientOptions {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
token: string;
|
|
4
|
+
/** Owner attributed to creates, snapshots, and template publication. */
|
|
5
|
+
ownerId?: string;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ExecOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
detached?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExecResult {
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
exitCode: number;
|
|
20
|
+
commandId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Snapshot {
|
|
24
|
+
id: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
uploadedToTigris?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CreateSandboxOptions {
|
|
31
|
+
ports?: number[];
|
|
32
|
+
templateId?: string;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
projectSlug?: string;
|
|
36
|
+
skipAppProcesses?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TemplateRuntimeContract {
|
|
40
|
+
/** Reserved for service-owned system template versions. */
|
|
41
|
+
immutableRef?: string;
|
|
42
|
+
startCommand?: string;
|
|
43
|
+
readinessPort?: number;
|
|
44
|
+
readinessPath?: string;
|
|
45
|
+
ports?: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Sandbox {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
exec(command: string, args?: string[], options?: ExecOptions): Promise<ExecResult>;
|
|
51
|
+
shell(script: string, options?: ExecOptions): Promise<ExecResult>;
|
|
52
|
+
writeFiles(files: Array<{ path: string; content: Uint8Array | string; mode?: number }>): Promise<void>;
|
|
53
|
+
snapshot(): Promise<Snapshot>;
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const TEMPLATE_ID = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
|
|
58
|
+
|
|
59
|
+
export function assertTemplateId(id: string): string {
|
|
60
|
+
const normalized = id.trim().toLowerCase();
|
|
61
|
+
if (!TEMPLATE_ID.test(normalized)) {
|
|
62
|
+
throw new Error(`invalid template id ${JSON.stringify(id)} (want 2-40 lowercase letters, digits, or hyphens)`);
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SandboxClient {
|
|
68
|
+
readonly baseUrl: string;
|
|
69
|
+
readonly ownerId?: string;
|
|
70
|
+
private readonly token: string;
|
|
71
|
+
private readonly fetchImpl: typeof fetch;
|
|
72
|
+
|
|
73
|
+
constructor(options: SandboxClientOptions) {
|
|
74
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
75
|
+
this.token = options.token;
|
|
76
|
+
this.ownerId = options.ownerId;
|
|
77
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
78
|
+
if (!this.baseUrl) throw new Error("sandbox baseUrl is required");
|
|
79
|
+
if (!this.token) throw new Error("sandbox token is required");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
83
|
+
const headers: Record<string, string> = {
|
|
84
|
+
authorization: `Bearer ${this.token}`,
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
};
|
|
87
|
+
if (this.ownerId) headers["x-on-behalf-of"] = this.ownerId;
|
|
88
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
89
|
+
method,
|
|
90
|
+
headers,
|
|
91
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
if (response.status === 204) return undefined as T;
|
|
94
|
+
const text = await response.text();
|
|
95
|
+
let parsed: unknown = text;
|
|
96
|
+
try { parsed = text ? JSON.parse(text) : undefined; } catch { /* keep text */ }
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const detail = parsed && typeof parsed === "object" && "error" in parsed
|
|
99
|
+
? String((parsed as { error: unknown }).error)
|
|
100
|
+
: text || `HTTP ${response.status}`;
|
|
101
|
+
throw new Error(`sandbox API ${method} ${path}: ${response.status} ${detail}`);
|
|
102
|
+
}
|
|
103
|
+
return parsed as T;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async create(options: CreateSandboxOptions = {}): Promise<Sandbox> {
|
|
107
|
+
const meta = await this.request<{ id: string }>("POST", "/v1/sandboxes", options);
|
|
108
|
+
return this.wrap(meta.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async getSnapshot(id: string): Promise<Snapshot> {
|
|
112
|
+
return this.request("GET", `/v1/snapshots/${encodeURIComponent(id)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async publishTemplate(templateId: string, snapshotId: string, runtime?: TemplateRuntimeContract): Promise<void> {
|
|
116
|
+
const id = assertTemplateId(templateId);
|
|
117
|
+
await this.request("POST", `/v1/templates/${id}/latest`, { snapshotId, ...runtime });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async resolveTemplate(templateId: string): Promise<{ templateId: string; snapshotId: string }> {
|
|
121
|
+
const id = assertTemplateId(templateId);
|
|
122
|
+
return this.request("GET", `/v1/templates/${id}/latest`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private wrap(id: string): Sandbox {
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
exec: (command, args = [], options = {}) => this.request("POST", `/v1/sandboxes/${id}/exec`, {
|
|
129
|
+
cmd: command, args, ...options,
|
|
130
|
+
}),
|
|
131
|
+
shell: (script, options = {}) => this.request("POST", `/v1/sandboxes/${id}/exec`, {
|
|
132
|
+
cmd: "/bin/bash", args: ["-lc", script], ...options,
|
|
133
|
+
}),
|
|
134
|
+
writeFiles: async (files) => {
|
|
135
|
+
await this.request("POST", `/v1/sandboxes/${id}/files`, files.map((file) => ({
|
|
136
|
+
path: file.path,
|
|
137
|
+
content: Buffer.from(file.content).toString("base64"),
|
|
138
|
+
mode: file.mode,
|
|
139
|
+
})));
|
|
140
|
+
},
|
|
141
|
+
snapshot: () => this.request("POST", `/v1/sandboxes/${id}/snapshot`),
|
|
142
|
+
stop: () => this.request("DELETE", `/v1/sandboxes/${id}`),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { apt, check, compileStartScript, compileStep, defineTemplate, download, run, templateRuntimeContract, templateVersionRef } from "./templates";
|
|
3
|
+
import { agentTemplates } from "../../../templates/agent-catalog";
|
|
4
|
+
|
|
5
|
+
describe("sandbox templates", () => {
|
|
6
|
+
test("defines a typed, immutable versioned template", () => {
|
|
7
|
+
const template = defineTemplate({ id: "angel-lfg", version: "12", title: "Angel", install: [apt.packages(["tmux"])], checks: [check.command("tmux")] });
|
|
8
|
+
expect(templateVersionRef(template)).toBe("angel-lfg-v12");
|
|
9
|
+
expect(Object.isFrozen(template)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("archive extraction ignores inherited tar policy", () => {
|
|
13
|
+
const { script } = compileStep(download.archive({ url: "https://example.com/a.tgz", destination: "/home/user/app" }));
|
|
14
|
+
expect(script).toContain("TAR_OPTIONS= tar");
|
|
15
|
+
expect(script).toContain("--no-same-owner");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("quotes run fields and writes a supervised startup contract", () => {
|
|
19
|
+
expect(compileStep(run({ command: "bun start", cwd: "/home/user/my app", user: "user", env: { VALUE: "a'b" } })).script).toContain("sudo -u 'user'");
|
|
20
|
+
const script = compileStartScript({ command: "bun start", user: "user", readiness: { port: 8766 } });
|
|
21
|
+
expect(script).toContain("/home/user/.omg/template/start.pid");
|
|
22
|
+
expect(script).toContain("http://127.0.0.1:8766/");
|
|
23
|
+
expect(() => compileStep(run({ command: "true", env: { "BAD-NAME": "x" } }))).toThrow("invalid environment variable");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("LFG owns every runtime dependency and zero-config transport default", () => {
|
|
27
|
+
const lfg = agentTemplates.lfg;
|
|
28
|
+
expect(templateVersionRef(lfg)).toBe("agent-lfg-v10");
|
|
29
|
+
expect(lfg.install).toContainEqual(apt.packages(["tmux"]));
|
|
30
|
+
expect(lfg.checks).toContainEqual(check.command("tmux"));
|
|
31
|
+
expect(lfg.start?.env?.LIVE_TRANSPORT).toBe("ws");
|
|
32
|
+
expect(lfg.start?.readiness?.port).toBe(8766);
|
|
33
|
+
expect(templateRuntimeContract(lfg, templateVersionRef(lfg))).toEqual({
|
|
34
|
+
immutableRef: "agent-lfg-v10",
|
|
35
|
+
startCommand: "exec /home/user/.omg/template/bootstrap.sh",
|
|
36
|
+
readinessPort: 8766,
|
|
37
|
+
readinessPath: "/",
|
|
38
|
+
ports: [8766],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/templates.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Sandbox, SandboxClient, Snapshot } from "./client.js";
|
|
3
|
+
import { assertTemplateId } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export type TemplateStep =
|
|
6
|
+
| { kind: "apt"; packages: readonly string[] }
|
|
7
|
+
| { kind: "archive"; url: string; destination: string; stripComponents?: number }
|
|
8
|
+
| { kind: "run"; command: string; cwd?: string; user?: string; env?: Record<string, string>; timeoutMs?: number };
|
|
9
|
+
|
|
10
|
+
export type TemplateCheck =
|
|
11
|
+
| { kind: "command"; command: string; user?: string }
|
|
12
|
+
| { kind: "file"; path: string };
|
|
13
|
+
|
|
14
|
+
export interface TemplateStart {
|
|
15
|
+
command: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
user?: string;
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
readiness?: { port: number; path?: string; timeoutSeconds?: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SandboxTemplate {
|
|
23
|
+
id: string;
|
|
24
|
+
version: string;
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
ports?: readonly number[];
|
|
28
|
+
install: readonly TemplateStep[];
|
|
29
|
+
checks?: readonly TemplateCheck[];
|
|
30
|
+
start?: TemplateStart;
|
|
31
|
+
metadata?: Record<string, string | number | boolean>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BakeResult {
|
|
35
|
+
templateId: string;
|
|
36
|
+
versionRef: string;
|
|
37
|
+
definitionHash: string;
|
|
38
|
+
snapshotId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const apt = {
|
|
42
|
+
packages(packages: readonly string[]): TemplateStep {
|
|
43
|
+
if (!packages.length) throw new Error("apt.packages requires at least one package");
|
|
44
|
+
for (const name of packages) if (!/^[a-z0-9][a-z0-9+.-]*$/.test(name)) throw new Error(`invalid apt package ${name}`);
|
|
45
|
+
return { kind: "apt", packages: [...packages] };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const download = {
|
|
50
|
+
archive(options: Omit<Extract<TemplateStep, { kind: "archive" }>, "kind">): TemplateStep {
|
|
51
|
+
return { kind: "archive", ...options };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function run(options: Omit<Extract<TemplateStep, { kind: "run" }>, "kind">): TemplateStep {
|
|
56
|
+
return { kind: "run", ...options };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const check = {
|
|
60
|
+
command(command: string, options: { user?: string } = {}): TemplateCheck {
|
|
61
|
+
return { kind: "command", command, ...options };
|
|
62
|
+
},
|
|
63
|
+
file(path: string): TemplateCheck { return { kind: "file", path }; },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function defineTemplate<const T extends SandboxTemplate>(definition: T): T {
|
|
67
|
+
assertTemplateId(definition.id);
|
|
68
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,19}$/.test(definition.version)) throw new Error("invalid template version");
|
|
69
|
+
if (!definition.title.trim()) throw new Error("template title is required");
|
|
70
|
+
for (const port of definition.ports ?? []) {
|
|
71
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid template port ${port}`);
|
|
72
|
+
}
|
|
73
|
+
return Object.freeze(definition);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function templateVersionRef(definition: SandboxTemplate): string {
|
|
77
|
+
const suffix = `-v${definition.version.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`;
|
|
78
|
+
const maxBase = 40 - suffix.length;
|
|
79
|
+
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}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function templateDefinitionHash(definition: SandboxTemplate): string {
|
|
84
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function templateRuntimeContract(definition: SandboxTemplate, immutableRef?: string) {
|
|
88
|
+
return {
|
|
89
|
+
immutableRef,
|
|
90
|
+
startCommand: definition.start ? "exec /home/user/.omg/template/bootstrap.sh" : undefined,
|
|
91
|
+
readinessPort: definition.start?.readiness?.port,
|
|
92
|
+
readinessPath: definition.start?.readiness?.path,
|
|
93
|
+
ports: [...(definition.ports ?? [])],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function quote(value: string): string { return `'${value.replaceAll("'", `'\"'\"'`)}'`; }
|
|
98
|
+
|
|
99
|
+
function commandForUser(command: string, user?: string): string {
|
|
100
|
+
if (!user) return `/bin/bash -lc ${quote(command)}`;
|
|
101
|
+
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)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function shellEnvAssignments(env: Record<string, string>): string[] {
|
|
106
|
+
return Object.entries(env).map(([key, value]) => {
|
|
107
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable ${JSON.stringify(key)}`);
|
|
108
|
+
return `${key}=${quote(value)}`;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function compileStep(step: TemplateStep): { script: string; timeoutMs?: number } {
|
|
113
|
+
if (step.kind === "apt") {
|
|
114
|
+
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
|
+
}
|
|
116
|
+
if (step.kind === "archive") {
|
|
117
|
+
const strip = step.stripComponents ?? 1;
|
|
118
|
+
if (!Number.isInteger(strip) || strip < 0 || strip > 20) throw new Error("invalid stripComponents");
|
|
119
|
+
return { script: `set -euo pipefail\ntmp=$(mktemp /tmp/template-archive-XXXXXX)\ntrap 'rm -f \"$tmp\"' EXIT\ncurl -fSL --retry 3 --retry-delay 2 ${quote(step.url)} -o \"$tmp\"\nmkdir -p ${quote(step.destination)}\nTAR_OPTIONS= tar -xzf \"$tmp\" -C ${quote(step.destination)} --strip-components=${strip} --overwrite --no-same-owner --no-same-permissions --touch` };
|
|
120
|
+
}
|
|
121
|
+
const env = shellEnvAssignments(step.env ?? {}).join(" ");
|
|
122
|
+
const inner = `${step.cwd ? `cd ${quote(step.cwd)} && ` : ""}${env ? `export ${env} && ` : ""}${step.command}`;
|
|
123
|
+
return { script: `set -euo pipefail\n${commandForUser(inner, step.user)}`, timeoutMs: step.timeoutMs };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function compileStartScript(start: TemplateStart): string {
|
|
127
|
+
const port = start.readiness?.port;
|
|
128
|
+
const timeout = start.readiness?.timeoutSeconds ?? 60;
|
|
129
|
+
const env = { HOME: start.user ? `/home/${start.user}` : "/root", ...(start.env ?? {}) };
|
|
130
|
+
const exports = shellEnvAssignments(env).map((assignment) => `export ${assignment}`).join("\n");
|
|
131
|
+
const command = `${start.cwd ? `cd ${quote(start.cwd)} && ` : ""}${start.command}`;
|
|
132
|
+
const foreground = commandForUser(`${exports}\n${command}`, start.user);
|
|
133
|
+
const probe = port
|
|
134
|
+
? `for _ in $(seq 1 ${timeout}); do curl -fsS -m1 -o /dev/null ${quote(`http://127.0.0.1:${port}${start.readiness?.path ?? "/"}`)} && exit 0; sleep 1; done\necho 'template service did not become ready on :${port}' >&2\nexit 1`
|
|
135
|
+
: "exit 0";
|
|
136
|
+
return `#!/usr/bin/env bash\nset -euo pipefail\nmkdir -p /home/user/.omg/template\npidfile=/home/user/.omg/template/start.pid\nif [ -s \"$pidfile\" ] && kill -0 \"$(cat \"$pidfile\")\" 2>/dev/null; then exit 0; fi\nnohup /bin/bash -lc ${quote(`while true; do ${foreground} >>/home/user/.omg/template/start.log 2>&1 || true; sleep 2; done`)} >/dev/null 2>&1 &\necho $! > \"$pidfile\"\n${probe}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function assertExec(label: string, result: { exitCode: number; stdout: string; stderr: string }): Promise<void> {
|
|
140
|
+
if (result.exitCode !== 0) throw new Error(`${label} failed (${result.exitCode}): ${(result.stderr || result.stdout).trim().slice(-1200)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function applyTemplate(sandbox: Sandbox, definition: SandboxTemplate, onLog: (line: string) => void = () => {}): Promise<void> {
|
|
144
|
+
for (const [index, step] of definition.install.entries()) {
|
|
145
|
+
onLog(`install ${index + 1}/${definition.install.length}: ${step.kind}`);
|
|
146
|
+
const compiled = compileStep(step);
|
|
147
|
+
await assertExec(`template step ${index + 1}`, await sandbox.shell(compiled.script, { timeoutMs: compiled.timeoutMs ?? 10 * 60_000 }));
|
|
148
|
+
}
|
|
149
|
+
if (definition.start) {
|
|
150
|
+
await sandbox.writeFiles([{ path: "/home/user/.omg/template/bootstrap.sh", content: compileStartScript(definition.start), mode: 0o755 }]);
|
|
151
|
+
}
|
|
152
|
+
const manifest = { ...definition, definitionHash: templateDefinitionHash(definition) };
|
|
153
|
+
await sandbox.writeFiles([{ path: "/home/user/.omg/template/definition.json", content: `${JSON.stringify(manifest, null, 2)}\n`, mode: 0o644 }]);
|
|
154
|
+
for (const item of definition.checks ?? []) {
|
|
155
|
+
const test = item.kind === "file" ? `test -e ${quote(item.path)}` : commandForUser(`command -v ${quote(item.command)}`, item.user);
|
|
156
|
+
await assertExec(`template check ${item.kind === "file" ? item.path : item.command}`, await sandbox.shell(`set -euo pipefail\n${test}`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function waitForSnapshotUpload(client: SandboxClient, snapshot: Snapshot, timeoutMs = 10 * 60_000): Promise<void> {
|
|
161
|
+
const deadline = Date.now() + timeoutMs;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
if ((await client.getSnapshot(snapshot.id)).uploadedToTigris) return;
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`snapshot ${snapshot.id} was not uploaded within ${timeoutMs}ms`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function bakeTemplate(client: SandboxClient, definition: SandboxTemplate, options: { onLog?: (line: string) => void; publishLatest?: boolean } = {}): Promise<BakeResult> {
|
|
170
|
+
const log = options.onLog ?? (() => {});
|
|
171
|
+
const versionRef = templateVersionRef(definition);
|
|
172
|
+
const sandbox = await client.create({ ports: [...(definition.ports ?? [])], skipAppProcesses: true, projectSlug: definition.id });
|
|
173
|
+
let snapshotted = false;
|
|
174
|
+
try {
|
|
175
|
+
await applyTemplate(sandbox, definition, log);
|
|
176
|
+
const snapshot = await sandbox.snapshot();
|
|
177
|
+
snapshotted = true;
|
|
178
|
+
await waitForSnapshotUpload(client, snapshot);
|
|
179
|
+
// A client with ownerId acts on behalf of a user and publishes into that
|
|
180
|
+
// 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 };
|
|
186
|
+
} finally {
|
|
187
|
+
if (!snapshotted) await sandbox.stop().catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
}
|