unfazed 0.9.0 → 0.10.1
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/CHANGELOG.md +46 -0
- package/README.md +59 -2
- package/REFERENCE.md +2 -2
- package/dist/cli/eve-sandbox.d.ts +3 -0
- package/dist/cli/eve-sandbox.js +268 -0
- package/dist/cli/eve-sandbox.js.map +1 -0
- package/dist/cli/login.d.ts +1 -1
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/eve-sandbox/daemon.d.ts +65 -0
- package/dist/eve-sandbox/daemon.js +687 -0
- package/dist/eve-sandbox/daemon.js.map +1 -0
- package/dist/eve-sandbox/index.d.ts +82 -0
- package/dist/eve-sandbox/index.js +368 -0
- package/dist/eve-sandbox/index.js.map +1 -0
- package/dist/eve-sandbox/protocol.d.ts +68 -0
- package/dist/eve-sandbox/protocol.js +22 -0
- package/dist/eve-sandbox/protocol.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/oidc.d.ts +3 -3
- package/dist/oidc.js +3 -3
- package/dist/oidc.js.map +1 -1
- package/dist/reference.generated.js +1 -1
- package/dist/reference.generated.js.map +1 -1
- package/package.json +14 -4
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eve sandbox daemon: runs as root inside an unfazed box and gives the
|
|
3
|
+
* apps it trusts sandboxes, one Unix user per eve session.
|
|
4
|
+
*
|
|
5
|
+
* It serves the box's :3000, which unfazed publishes at the box's URL, and
|
|
6
|
+
* accepts only workload identity tokens (OIDC, issuer
|
|
7
|
+
* `https://unfazed.dev/oidc`) whose audience is that URL and whose app is on
|
|
8
|
+
* its allow list. A session's commands and file operations run as that
|
|
9
|
+
* session's user, so the kernel keeps sessions apart; the daemon itself only
|
|
10
|
+
* creates and removes users and relays bytes.
|
|
11
|
+
*
|
|
12
|
+
* Started by a box boot hook (`/data/boot.d/50-eve-sandbox`, see setup.ts):
|
|
13
|
+
*
|
|
14
|
+
* node|bun <this file> serve [--config /data/eve-sandbox/config.json]
|
|
15
|
+
*
|
|
16
|
+
* and invoked as the session user for file operations:
|
|
17
|
+
*
|
|
18
|
+
* setpriv --reuid U --regid G --init-groups -- node|bun <this file> fileop
|
|
19
|
+
*/
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import { createHash, randomUUID, webcrypto } from "node:crypto";
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { createServer } from "node:http";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { API_PREFIX, DAEMON_VERSION, TEMPLATE_HOME, TEMPLATE_WORKSPACE, } from "./protocol.js";
|
|
27
|
+
const DEFAULT_CONFIG_PATH = "/data/eve-sandbox/config.json";
|
|
28
|
+
/**
|
|
29
|
+
* The box's env snapshot (the work drive, rebuilt on every boot). The box
|
|
30
|
+
* init exports it to SSH sessions but not to boot hooks, so a daemon started
|
|
31
|
+
* at boot reads the box's URL from here.
|
|
32
|
+
*/
|
|
33
|
+
export const BOX_ENV_SNAPSHOT = "/work/.unfazed/env.json";
|
|
34
|
+
const PATH_ENV = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
35
|
+
/** Output kept per process for a client that reads late or reconnects. */
|
|
36
|
+
const MAX_PROCESS_OUTPUT = 64 * 1024 * 1024;
|
|
37
|
+
/** A finished process is forgotten this long after it exited. */
|
|
38
|
+
const PROCESS_RETENTION_MS = 10 * 60 * 1000;
|
|
39
|
+
const MAX_BODY = 128 * 1024 * 1024;
|
|
40
|
+
/** Root-owned file in a session's home whose mtime is the session's last call. */
|
|
41
|
+
const LAST_USED = ".eve-sandbox-last-used";
|
|
42
|
+
const SESSION_USER = /^eve[0-9a-f]{12}$/;
|
|
43
|
+
/** The box's own variables: the process's, else the box's env snapshot. */
|
|
44
|
+
function boxEnv(env, snapshot) {
|
|
45
|
+
if (env.UNFZ_PUBLIC_URL)
|
|
46
|
+
return env;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(snapshot, "utf8"));
|
|
49
|
+
const out = { ...env };
|
|
50
|
+
for (const [key, value] of Object.entries(parsed))
|
|
51
|
+
if (typeof value === "string" && out[key] === undefined)
|
|
52
|
+
out[key] = value;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function loadConfig(path = DEFAULT_CONFIG_PATH, env = process.env, snapshot = BOX_ENV_SNAPSHOT) {
|
|
60
|
+
const raw = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
61
|
+
const box = boxEnv(env, snapshot);
|
|
62
|
+
const audience = raw.audience ?? box.UNFZ_PUBLIC_URL ?? "";
|
|
63
|
+
if (!audience)
|
|
64
|
+
throw new Error(`eve sandbox: no audience (set "audience" in ${path}, or UNFZ_PUBLIC_URL; ${snapshot} has none either)`);
|
|
65
|
+
const envAllow = (box.EVE_SANDBOX_ALLOW ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
66
|
+
const allow = [...(raw.allow ?? []), ...envAllow];
|
|
67
|
+
if (allow.length === 0)
|
|
68
|
+
throw new Error(`eve sandbox: nothing is allowed (set "allow" in ${path} or EVE_SANDBOX_ALLOW)`);
|
|
69
|
+
return {
|
|
70
|
+
audience: audience.replace(/\/+$/, ""),
|
|
71
|
+
allow,
|
|
72
|
+
issuer: raw.issuer ?? "https://unfazed.dev/oidc",
|
|
73
|
+
port: raw.port ?? 3000,
|
|
74
|
+
homeRoot: raw.homeRoot ?? "/home",
|
|
75
|
+
idleMinutes: raw.idleMinutes ?? 20,
|
|
76
|
+
retentionDays: raw.retentionDays ?? 14,
|
|
77
|
+
env: raw.env ?? {},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
class HttpError extends Error {
|
|
81
|
+
status;
|
|
82
|
+
code;
|
|
83
|
+
constructor(status, code, message) {
|
|
84
|
+
super(message ?? code);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.code = code;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function b64urlDecode(part) {
|
|
90
|
+
return new Uint8Array(Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
|
|
91
|
+
}
|
|
92
|
+
export class TokenVerifier {
|
|
93
|
+
config;
|
|
94
|
+
fetchJson;
|
|
95
|
+
now;
|
|
96
|
+
keys = new Map();
|
|
97
|
+
fetchedAt = 0;
|
|
98
|
+
refreshing;
|
|
99
|
+
constructor(config, fetchJson = async (url) => {
|
|
100
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`${url}: ${res.status}`);
|
|
103
|
+
return await res.json();
|
|
104
|
+
}, now = () => Date.now()) {
|
|
105
|
+
this.config = config;
|
|
106
|
+
this.fetchJson = fetchJson;
|
|
107
|
+
this.now = now;
|
|
108
|
+
}
|
|
109
|
+
/** The calling app's namespace (its app id) for a valid, allowed token. */
|
|
110
|
+
async verify(authorization) {
|
|
111
|
+
const match = /^Bearer ([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(authorization ?? "");
|
|
112
|
+
if (!match)
|
|
113
|
+
throw new HttpError(401, "invalid_token", "a workload identity bearer token is required");
|
|
114
|
+
const [, h, p, s] = match;
|
|
115
|
+
let header;
|
|
116
|
+
let claims;
|
|
117
|
+
try {
|
|
118
|
+
header = JSON.parse(Buffer.from(b64urlDecode(h)).toString("utf8"));
|
|
119
|
+
claims = JSON.parse(Buffer.from(b64urlDecode(p)).toString("utf8"));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new HttpError(401, "invalid_token", "malformed token");
|
|
123
|
+
}
|
|
124
|
+
if (header.alg !== "ES256" || !header.kid)
|
|
125
|
+
throw new HttpError(401, "invalid_token", "unsupported token algorithm");
|
|
126
|
+
const key = await this.key(header.kid);
|
|
127
|
+
const ok = await webcrypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, b64urlDecode(s), new TextEncoder().encode(`${h}.${p}`));
|
|
128
|
+
if (!ok)
|
|
129
|
+
throw new HttpError(401, "invalid_token", "bad signature");
|
|
130
|
+
const nowS = Math.floor(this.now() / 1000);
|
|
131
|
+
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
132
|
+
if (claims.iss !== this.config.issuer)
|
|
133
|
+
throw new HttpError(401, "invalid_token", "wrong issuer");
|
|
134
|
+
if (!audiences.some((a) => a.replace(/\/+$/, "") === this.config.audience))
|
|
135
|
+
throw new HttpError(401, "invalid_token", "wrong audience");
|
|
136
|
+
if (typeof claims.exp !== "number" || claims.exp < nowS - 30)
|
|
137
|
+
throw new HttpError(401, "invalid_token", "token expired");
|
|
138
|
+
if (typeof claims.nbf === "number" && claims.nbf > nowS + 30)
|
|
139
|
+
throw new HttpError(401, "invalid_token", "token not yet valid");
|
|
140
|
+
const app = claims.app_id ?? "";
|
|
141
|
+
if (!app)
|
|
142
|
+
throw new HttpError(403, "forbidden", "the token names no app");
|
|
143
|
+
const allowed = this.config.allow.some((entry) => entry.startsWith("owner:") ? entry.slice(6) === claims.owner_id : entry === app);
|
|
144
|
+
if (!allowed)
|
|
145
|
+
throw new HttpError(403, "forbidden", `app ${app} may not use this box`);
|
|
146
|
+
return app;
|
|
147
|
+
}
|
|
148
|
+
async key(kid) {
|
|
149
|
+
const cached = this.keys.get(kid);
|
|
150
|
+
if (cached && this.now() - this.fetchedAt < 10 * 60 * 1000)
|
|
151
|
+
return cached;
|
|
152
|
+
// Unknown kid: refetch, but at most every 30 s.
|
|
153
|
+
if (!cached || this.now() - this.fetchedAt >= 30_000)
|
|
154
|
+
await this.refresh();
|
|
155
|
+
const key = this.keys.get(kid);
|
|
156
|
+
if (!key)
|
|
157
|
+
throw new HttpError(401, "invalid_token", "unknown signing key");
|
|
158
|
+
return key;
|
|
159
|
+
}
|
|
160
|
+
async refresh() {
|
|
161
|
+
this.refreshing ??= (async () => {
|
|
162
|
+
try {
|
|
163
|
+
const discovery = (await this.fetchJson(`${this.config.issuer}/.well-known/openid-configuration`));
|
|
164
|
+
const jwks = (await this.fetchJson(discovery.jwks_uri ?? `${this.config.issuer}/jwks`));
|
|
165
|
+
const next = new Map();
|
|
166
|
+
for (const jwk of jwks.keys ?? []) {
|
|
167
|
+
const kid = jwk.kid;
|
|
168
|
+
if (!kid || jwk.kty !== "EC" || jwk.crv !== "P-256")
|
|
169
|
+
continue;
|
|
170
|
+
next.set(kid, await webcrypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]));
|
|
171
|
+
}
|
|
172
|
+
this.keys = next;
|
|
173
|
+
this.fetchedAt = this.now();
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
this.refreshing = undefined;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
await this.refreshing;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** The Unix name of an app's session: bounded, derived from the ids only. */
|
|
183
|
+
export function sessionName(app, sessionId) {
|
|
184
|
+
return `eve${createHash("sha256").update(`${app}\n${sessionId}`).digest("hex").slice(0, 12)}`;
|
|
185
|
+
}
|
|
186
|
+
function run(argv, input) {
|
|
187
|
+
const result = spawnSync(argv[0], argv.slice(1), { input, encoding: "utf8", env: { PATH: PATH_ENV } });
|
|
188
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
189
|
+
}
|
|
190
|
+
class Sessions {
|
|
191
|
+
config;
|
|
192
|
+
lastUsed = new Map();
|
|
193
|
+
constructor(config) {
|
|
194
|
+
this.config = config;
|
|
195
|
+
}
|
|
196
|
+
describe(name) {
|
|
197
|
+
const entry = run(["getent", "passwd", name]);
|
|
198
|
+
if (entry.status !== 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
const [, , uid, gid, , home] = entry.stdout.trim().split(":");
|
|
201
|
+
return { name, uid: Number(uid), gid: Number(gid), home: home, workspace: `${home}/workspace` };
|
|
202
|
+
}
|
|
203
|
+
get(app, sessionId) {
|
|
204
|
+
const session = this.describe(sessionName(app, sessionId));
|
|
205
|
+
if (session)
|
|
206
|
+
this.touch(session.name);
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
209
|
+
ensure(app, sessionId, template) {
|
|
210
|
+
const name = sessionName(app, sessionId);
|
|
211
|
+
const existing = this.describe(name);
|
|
212
|
+
if (existing) {
|
|
213
|
+
this.touch(name);
|
|
214
|
+
return { ...existing, created: false };
|
|
215
|
+
}
|
|
216
|
+
const home = `${this.config.homeRoot}/${name}`;
|
|
217
|
+
const added = run(["useradd", "--create-home", "--home-dir", home, "--shell", "/bin/bash", "--user-group", "--comment", "eve sandbox", name]);
|
|
218
|
+
const session = this.describe(name);
|
|
219
|
+
if (!session)
|
|
220
|
+
throw new HttpError(500, "session_create_failed", added.stderr.trim() || "useradd failed");
|
|
221
|
+
run(["chmod", "0700", home]);
|
|
222
|
+
const files = [
|
|
223
|
+
{ path: `${session.workspace}/.keep`, b64: "" },
|
|
224
|
+
...template.map((file) => ({ path: mapTemplatePath(file.path, session), b64: file.b64 })),
|
|
225
|
+
];
|
|
226
|
+
fileop(session, { op: "write", files });
|
|
227
|
+
this.touch(name);
|
|
228
|
+
return { ...session, created: true };
|
|
229
|
+
}
|
|
230
|
+
stop(name) {
|
|
231
|
+
run(["pkill", "-KILL", "-u", name]);
|
|
232
|
+
}
|
|
233
|
+
delete(app, sessionId) {
|
|
234
|
+
const name = sessionName(app, sessionId);
|
|
235
|
+
if (!this.describe(name))
|
|
236
|
+
return;
|
|
237
|
+
this.stop(name);
|
|
238
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
239
|
+
if (run(["userdel", "--remove", name]).status === 0)
|
|
240
|
+
break;
|
|
241
|
+
this.stop(name);
|
|
242
|
+
spawnSync("sleep", ["0.2"]);
|
|
243
|
+
}
|
|
244
|
+
rmSync(`${this.config.homeRoot}/${name}`, { recursive: true, force: true });
|
|
245
|
+
this.lastUsed.delete(name);
|
|
246
|
+
}
|
|
247
|
+
touch(name) {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const previous = this.lastUsed.get(name) ?? 0;
|
|
250
|
+
this.lastUsed.set(name, now);
|
|
251
|
+
if (now - previous < 60_000)
|
|
252
|
+
return;
|
|
253
|
+
const marker = `${this.config.homeRoot}/${name}/${LAST_USED}`;
|
|
254
|
+
try {
|
|
255
|
+
if (!existsSync(marker))
|
|
256
|
+
writeFileSync(marker, "");
|
|
257
|
+
utimesSync(marker, now / 1000, now / 1000);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// the home may be gone mid-delete
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Deletes sessions without a call for longer than the retention, users and homes included. */
|
|
264
|
+
expireOld() {
|
|
265
|
+
if (this.config.retentionDays <= 0)
|
|
266
|
+
return;
|
|
267
|
+
const limit = this.config.retentionDays * 24 * 60 * 60 * 1000;
|
|
268
|
+
const passwd = run(["getent", "passwd"]).stdout.split("\n");
|
|
269
|
+
for (const line of passwd) {
|
|
270
|
+
const [name, , , , , home] = line.split(":");
|
|
271
|
+
if (!name || !SESSION_USER.test(name) || home !== `${this.config.homeRoot}/${name}`)
|
|
272
|
+
continue;
|
|
273
|
+
let at;
|
|
274
|
+
try {
|
|
275
|
+
at = statSync(`${home}/${LAST_USED}`).mtimeMs;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
try {
|
|
279
|
+
at = statSync(home).mtimeMs;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
at = 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (Date.now() - at > limit) {
|
|
286
|
+
this.stop(name);
|
|
287
|
+
run(["userdel", "--remove", name]);
|
|
288
|
+
rmSync(home, { recursive: true, force: true });
|
|
289
|
+
this.lastUsed.delete(name);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Kills the processes of sessions idle for longer than the limit. */
|
|
294
|
+
reapIdle() {
|
|
295
|
+
if (this.config.idleMinutes <= 0)
|
|
296
|
+
return;
|
|
297
|
+
const limit = this.config.idleMinutes * 60 * 1000;
|
|
298
|
+
for (const [name, at] of this.lastUsed) {
|
|
299
|
+
if (Date.now() - at > limit) {
|
|
300
|
+
this.stop(name);
|
|
301
|
+
this.lastUsed.delete(name);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function mapTemplatePath(path, session) {
|
|
307
|
+
if (path === TEMPLATE_WORKSPACE || path.startsWith(`${TEMPLATE_WORKSPACE}/`))
|
|
308
|
+
return session.workspace + path.slice(TEMPLATE_WORKSPACE.length);
|
|
309
|
+
if (path === TEMPLATE_HOME || path.startsWith(`${TEMPLATE_HOME}/`))
|
|
310
|
+
return session.home + path.slice(TEMPLATE_HOME.length);
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
function sessionEnv(session, extra) {
|
|
314
|
+
return {
|
|
315
|
+
PATH: PATH_ENV,
|
|
316
|
+
HOME: session.home,
|
|
317
|
+
USER: session.name,
|
|
318
|
+
LOGNAME: session.name,
|
|
319
|
+
SHELL: "/bin/bash",
|
|
320
|
+
LANG: "C.UTF-8",
|
|
321
|
+
...extra,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function asUser(session, argv) {
|
|
325
|
+
return ["setpriv", `--reuid=${session.uid}`, `--regid=${session.gid}`, "--init-groups", "--", ...argv];
|
|
326
|
+
}
|
|
327
|
+
const FILEOP_MISSING = 3;
|
|
328
|
+
const FILEOP_DIRECTORY = 4;
|
|
329
|
+
const FILEOP_FAILED = 5;
|
|
330
|
+
function fileop(session, request) {
|
|
331
|
+
// This module itself, whoever loaded it (its own entry or `unfazed eve-sandbox serve`).
|
|
332
|
+
const argv = asUser(session, [process.execPath, fileURLToPath(import.meta.url), "fileop"]);
|
|
333
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
334
|
+
input: JSON.stringify(request),
|
|
335
|
+
env: { PATH: PATH_ENV, HOME: session.home },
|
|
336
|
+
maxBuffer: MAX_BODY,
|
|
337
|
+
});
|
|
338
|
+
const status = result.status ?? FILEOP_FAILED;
|
|
339
|
+
if (status === 0)
|
|
340
|
+
return result.stdout;
|
|
341
|
+
const message = (result.stderr?.toString() ?? "").trim().slice(0, 500);
|
|
342
|
+
if (status === FILEOP_MISSING)
|
|
343
|
+
throw new HttpError(404, "not_found", message || "no such file");
|
|
344
|
+
if (status === FILEOP_DIRECTORY)
|
|
345
|
+
throw new HttpError(409, "is_directory", message || "the path is a directory");
|
|
346
|
+
throw new HttpError(422, "file_operation_failed", message || "the file operation failed");
|
|
347
|
+
}
|
|
348
|
+
/** The `fileop` subcommand: one request on stdin, run with the caller's own permissions. */
|
|
349
|
+
export function runFileOp() {
|
|
350
|
+
try {
|
|
351
|
+
const request = JSON.parse(readFileSync(0, "utf8"));
|
|
352
|
+
if (request.op === "read") {
|
|
353
|
+
let st;
|
|
354
|
+
try {
|
|
355
|
+
st = statSync(request.path);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error.code === "ENOENT")
|
|
359
|
+
process.exit(FILEOP_MISSING);
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
if (st.isDirectory())
|
|
363
|
+
process.exit(FILEOP_DIRECTORY);
|
|
364
|
+
process.stdout.write(readFileSync(request.path), () => process.exit(0));
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
if (request.op === "write") {
|
|
368
|
+
for (const file of request.files) {
|
|
369
|
+
if (!file.path.startsWith("/"))
|
|
370
|
+
throw new Error(`not an absolute path: ${file.path}`);
|
|
371
|
+
mkdirSync(dirname(file.path), { recursive: true });
|
|
372
|
+
writeFileSync(file.path, Buffer.from(file.b64, "base64"));
|
|
373
|
+
}
|
|
374
|
+
process.exit(0);
|
|
375
|
+
}
|
|
376
|
+
if (request.op === "remove") {
|
|
377
|
+
if (!existsSync(request.path))
|
|
378
|
+
process.exit(request.force ? 0 : FILEOP_MISSING);
|
|
379
|
+
rmSync(request.path, { recursive: request.recursive, force: request.force });
|
|
380
|
+
process.exit(0);
|
|
381
|
+
}
|
|
382
|
+
throw new Error("unknown operation");
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
process.stderr.write(String(error.message ?? error));
|
|
386
|
+
process.exit(FILEOP_FAILED);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const SIGNALS = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
|
|
390
|
+
class Processes {
|
|
391
|
+
all = new Map();
|
|
392
|
+
start(app, session, request, baseEnv) {
|
|
393
|
+
if (!existsSync(request.cwd))
|
|
394
|
+
throw new HttpError(400, "invalid_cwd", `working directory does not exist: ${request.cwd}`);
|
|
395
|
+
const argv = asUser(session, ["bash", "-lc", request.command]);
|
|
396
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
397
|
+
cwd: request.cwd,
|
|
398
|
+
env: { ...sessionEnv(session, { AI_AGENT: "eve", ...baseEnv }), ...request.env },
|
|
399
|
+
stdio: [request.stdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
400
|
+
// Its own process group, so a kill reaches the whole tree.
|
|
401
|
+
detached: true,
|
|
402
|
+
});
|
|
403
|
+
const proc = {
|
|
404
|
+
id: randomUUID(),
|
|
405
|
+
app,
|
|
406
|
+
session: session.name,
|
|
407
|
+
child,
|
|
408
|
+
events: [],
|
|
409
|
+
bytes: 0,
|
|
410
|
+
exit: undefined,
|
|
411
|
+
exitedAt: undefined,
|
|
412
|
+
listeners: new Set(),
|
|
413
|
+
};
|
|
414
|
+
const push = (event) => {
|
|
415
|
+
proc.events.push(event);
|
|
416
|
+
for (const listener of proc.listeners)
|
|
417
|
+
listener();
|
|
418
|
+
};
|
|
419
|
+
const pipe = (fd) => (chunk) => {
|
|
420
|
+
if (proc.bytes + chunk.length > MAX_PROCESS_OUTPUT)
|
|
421
|
+
return;
|
|
422
|
+
proc.bytes += chunk.length;
|
|
423
|
+
push({ s: proc.events.length + 1, f: fd, d: chunk.toString("base64") });
|
|
424
|
+
};
|
|
425
|
+
child.stdout?.on("data", pipe(1));
|
|
426
|
+
child.stderr?.on("data", pipe(2));
|
|
427
|
+
child.on("error", (error) => {
|
|
428
|
+
push({ s: proc.events.length + 1, f: 2, d: Buffer.from(`eve sandbox: ${error.message}\n`).toString("base64") });
|
|
429
|
+
});
|
|
430
|
+
child.on("close", (code, signal) => {
|
|
431
|
+
proc.exit = code ?? 128 + (signal ? (SIGNALS[signal] ?? 1) : 1);
|
|
432
|
+
proc.exitedAt = Date.now();
|
|
433
|
+
push({ s: proc.events.length + 1, x: proc.exit });
|
|
434
|
+
});
|
|
435
|
+
this.all.set(proc.id, proc);
|
|
436
|
+
return proc;
|
|
437
|
+
}
|
|
438
|
+
kill(proc) {
|
|
439
|
+
if (proc.exit !== undefined || proc.child.pid === undefined)
|
|
440
|
+
return;
|
|
441
|
+
try {
|
|
442
|
+
process.kill(-proc.child.pid, "SIGKILL");
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
proc.child.kill("SIGKILL");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
killSession(name) {
|
|
449
|
+
for (const proc of this.all.values())
|
|
450
|
+
if (proc.session === name)
|
|
451
|
+
this.kill(proc);
|
|
452
|
+
}
|
|
453
|
+
prune() {
|
|
454
|
+
for (const [id, proc] of this.all) {
|
|
455
|
+
if (proc.exitedAt !== undefined && Date.now() - proc.exitedAt > PROCESS_RETENTION_MS)
|
|
456
|
+
this.all.delete(id);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// HTTP
|
|
462
|
+
async function readBody(req) {
|
|
463
|
+
const chunks = [];
|
|
464
|
+
let size = 0;
|
|
465
|
+
for await (const chunk of req) {
|
|
466
|
+
size += chunk.length;
|
|
467
|
+
if (size > MAX_BODY)
|
|
468
|
+
throw new HttpError(413, "too_large");
|
|
469
|
+
chunks.push(chunk);
|
|
470
|
+
}
|
|
471
|
+
return Buffer.concat(chunks);
|
|
472
|
+
}
|
|
473
|
+
async function readJson(req) {
|
|
474
|
+
const body = await readBody(req);
|
|
475
|
+
try {
|
|
476
|
+
return JSON.parse(body.toString("utf8") || "{}");
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
throw new HttpError(400, "invalid_json");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function sendJson(res, status, value) {
|
|
483
|
+
const body = JSON.stringify(value);
|
|
484
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body), "cache-control": "no-store" });
|
|
485
|
+
res.end(body);
|
|
486
|
+
}
|
|
487
|
+
export function createDaemon(config, verifier = new TokenVerifier(config)) {
|
|
488
|
+
const sessions = new Sessions(config);
|
|
489
|
+
const processes = new Processes();
|
|
490
|
+
const sessionOf = (app, id) => {
|
|
491
|
+
const session = sessions.get(app, decodeURIComponent(id));
|
|
492
|
+
if (!session)
|
|
493
|
+
throw new HttpError(410, "session_gone", "no such sandbox session");
|
|
494
|
+
return session;
|
|
495
|
+
};
|
|
496
|
+
const procOf = (app, id) => {
|
|
497
|
+
const proc = processes.all.get(id);
|
|
498
|
+
if (!proc || proc.app !== app)
|
|
499
|
+
throw new HttpError(404, "no_such_process");
|
|
500
|
+
sessions.touch(proc.session);
|
|
501
|
+
return proc;
|
|
502
|
+
};
|
|
503
|
+
async function route(req, res) {
|
|
504
|
+
const url = new URL(req.url ?? "/", "http://box");
|
|
505
|
+
const path = url.pathname;
|
|
506
|
+
const method = req.method ?? "GET";
|
|
507
|
+
if (method === "GET" && path === `${API_PREFIX}/health`)
|
|
508
|
+
return sendJson(res, 200, { ok: true, version: DAEMON_VERSION });
|
|
509
|
+
if (!path.startsWith(`${API_PREFIX}/`))
|
|
510
|
+
throw new HttpError(404, "not_found");
|
|
511
|
+
const app = await verifier.verify(req.headers.authorization);
|
|
512
|
+
const parts = path.slice(API_PREFIX.length + 1).split("/");
|
|
513
|
+
if (parts[0] === "sessions" && parts[1]) {
|
|
514
|
+
const sessionId = decodeURIComponent(parts[1]);
|
|
515
|
+
const sub = parts[2];
|
|
516
|
+
if (!sub && method === "PUT") {
|
|
517
|
+
const body = await readJson(req);
|
|
518
|
+
const session = sessions.ensure(app, sessionId, body.template ?? []);
|
|
519
|
+
return sendJson(res, 200, { name: session.name, home: session.home, workspace: session.workspace, created: session.created });
|
|
520
|
+
}
|
|
521
|
+
if (!sub && method === "GET") {
|
|
522
|
+
const session = sessionOf(app, parts[1]);
|
|
523
|
+
return sendJson(res, 200, { name: session.name, home: session.home, workspace: session.workspace });
|
|
524
|
+
}
|
|
525
|
+
if (!sub && method === "DELETE") {
|
|
526
|
+
processes.killSession(sessionName(app, sessionId));
|
|
527
|
+
sessions.delete(app, sessionId);
|
|
528
|
+
return sendJson(res, 200, { deleted: true });
|
|
529
|
+
}
|
|
530
|
+
const session = sessionOf(app, parts[1]);
|
|
531
|
+
if (sub === "stop" && method === "POST") {
|
|
532
|
+
processes.killSession(session.name);
|
|
533
|
+
sessions.stop(session.name);
|
|
534
|
+
return sendJson(res, 200, { stopped: true });
|
|
535
|
+
}
|
|
536
|
+
if (sub === "processes" && method === "POST") {
|
|
537
|
+
const body = await readJson(req);
|
|
538
|
+
if (typeof body.command !== "string" || typeof body.cwd !== "string" || !body.cwd.startsWith("/"))
|
|
539
|
+
throw new HttpError(400, "invalid_request");
|
|
540
|
+
const env = {};
|
|
541
|
+
for (const [key, value] of Object.entries(body.env ?? {}))
|
|
542
|
+
if (typeof value === "string")
|
|
543
|
+
env[key] = value;
|
|
544
|
+
const proc = processes.start(app, session, { command: body.command, cwd: body.cwd, env, stdin: body.stdin === true }, config.env);
|
|
545
|
+
return sendJson(res, 200, { id: proc.id, pid: proc.child.pid });
|
|
546
|
+
}
|
|
547
|
+
if (sub === "file" && method === "GET") {
|
|
548
|
+
const target = url.searchParams.get("path") ?? "";
|
|
549
|
+
if (!target.startsWith("/"))
|
|
550
|
+
throw new HttpError(400, "invalid_path");
|
|
551
|
+
const bytes = fileop(session, { op: "read", path: target });
|
|
552
|
+
res.writeHead(200, { "content-type": "application/octet-stream", "content-length": bytes.length, "cache-control": "no-store" });
|
|
553
|
+
res.end(bytes);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (sub === "files" && method === "POST") {
|
|
557
|
+
const body = await readJson(req);
|
|
558
|
+
const files = (body.files ?? []).filter((f) => typeof f.path === "string" && typeof f.b64 === "string");
|
|
559
|
+
if (files.some((f) => !f.path.startsWith("/")))
|
|
560
|
+
throw new HttpError(400, "invalid_path");
|
|
561
|
+
if (files.length > 0)
|
|
562
|
+
fileop(session, { op: "write", files });
|
|
563
|
+
return sendJson(res, 200, { written: files.length });
|
|
564
|
+
}
|
|
565
|
+
if (sub === "remove" && method === "POST") {
|
|
566
|
+
const body = await readJson(req);
|
|
567
|
+
if (typeof body.path !== "string" || !body.path.startsWith("/"))
|
|
568
|
+
throw new HttpError(400, "invalid_path");
|
|
569
|
+
fileop(session, { op: "remove", path: body.path, force: body.force === true, recursive: body.recursive === true });
|
|
570
|
+
return sendJson(res, 200, { removed: true });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (parts[0] === "processes" && parts[1]) {
|
|
574
|
+
const proc = procOf(app, parts[1]);
|
|
575
|
+
const sub = parts[2];
|
|
576
|
+
if (sub === "events" && method === "GET")
|
|
577
|
+
return streamEvents(req, res, proc, Number(url.searchParams.get("after") ?? "0") || 0);
|
|
578
|
+
if (sub === "stdin" && method === "POST") {
|
|
579
|
+
const body = await readBody(req);
|
|
580
|
+
const stdin = proc.child.stdin;
|
|
581
|
+
if (!stdin)
|
|
582
|
+
throw new HttpError(409, "no_stdin");
|
|
583
|
+
if (body.length > 0)
|
|
584
|
+
await new Promise((resolve, reject) => stdin.write(body, (e) => (e ? reject(e) : resolve())));
|
|
585
|
+
if (url.searchParams.get("eof") === "1")
|
|
586
|
+
stdin.end();
|
|
587
|
+
return sendJson(res, 200, { written: body.length });
|
|
588
|
+
}
|
|
589
|
+
if (sub === "kill" && method === "POST") {
|
|
590
|
+
processes.kill(proc);
|
|
591
|
+
return sendJson(res, 200, { killed: true });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
throw new HttpError(404, "not_found");
|
|
595
|
+
}
|
|
596
|
+
function streamEvents(req, res, proc, after) {
|
|
597
|
+
res.writeHead(200, {
|
|
598
|
+
"content-type": "text/event-stream",
|
|
599
|
+
"cache-control": "no-store, no-transform",
|
|
600
|
+
"x-accel-buffering": "no",
|
|
601
|
+
connection: "keep-alive",
|
|
602
|
+
});
|
|
603
|
+
let sent = after;
|
|
604
|
+
const flush = () => {
|
|
605
|
+
while (sent < proc.events.length) {
|
|
606
|
+
const event = proc.events[sent];
|
|
607
|
+
sent = event.s;
|
|
608
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
609
|
+
if ("x" in event) {
|
|
610
|
+
finish();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const heartbeat = setInterval(() => res.write(": keep-alive\n\n"), 15_000);
|
|
616
|
+
const finish = () => {
|
|
617
|
+
clearInterval(heartbeat);
|
|
618
|
+
proc.listeners.delete(flush);
|
|
619
|
+
res.end();
|
|
620
|
+
};
|
|
621
|
+
req.on("close", () => {
|
|
622
|
+
clearInterval(heartbeat);
|
|
623
|
+
proc.listeners.delete(flush);
|
|
624
|
+
});
|
|
625
|
+
proc.listeners.add(flush);
|
|
626
|
+
flush();
|
|
627
|
+
}
|
|
628
|
+
const server = createServer((req, res) => {
|
|
629
|
+
route(req, res).catch((error) => {
|
|
630
|
+
if (res.headersSent) {
|
|
631
|
+
res.end();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (error instanceof HttpError)
|
|
635
|
+
return sendJson(res, error.status, { error: error.code, message: error.message });
|
|
636
|
+
sendJson(res, 500, { error: "internal", message: String(error?.message ?? error).slice(0, 300) });
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
server.requestTimeout = 0;
|
|
640
|
+
server.headersTimeout = 60_000;
|
|
641
|
+
const timers = [
|
|
642
|
+
setInterval(() => processes.prune(), 60_000),
|
|
643
|
+
setInterval(() => sessions.reapIdle(), 60_000),
|
|
644
|
+
setInterval(() => sessions.expireOld(), 60 * 60 * 1000),
|
|
645
|
+
];
|
|
646
|
+
return {
|
|
647
|
+
server,
|
|
648
|
+
listen(port = config.port) {
|
|
649
|
+
return new Promise((resolve) => server.listen(port, "0.0.0.0", () => resolve()));
|
|
650
|
+
},
|
|
651
|
+
close() {
|
|
652
|
+
for (const timer of timers)
|
|
653
|
+
clearInterval(timer);
|
|
654
|
+
server.close();
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Entry point
|
|
660
|
+
function argValue(flag) {
|
|
661
|
+
const index = process.argv.indexOf(flag);
|
|
662
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
663
|
+
}
|
|
664
|
+
const isMain = (() => {
|
|
665
|
+
try {
|
|
666
|
+
return import.meta.url === `file://${process.argv[1]}` || (process.argv[1] ?? "").endsWith("eve-sandbox-daemon.js");
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
})();
|
|
672
|
+
if (isMain) {
|
|
673
|
+
const command = process.argv[2];
|
|
674
|
+
if (command === "fileop")
|
|
675
|
+
runFileOp();
|
|
676
|
+
if (command === "serve") {
|
|
677
|
+
const config = loadConfig(argValue("--config"));
|
|
678
|
+
const daemon = createDaemon(config);
|
|
679
|
+
await daemon.listen();
|
|
680
|
+
process.stdout.write(`eve sandbox daemon ${DAEMON_VERSION} on :${config.port} for ${config.audience}, ${config.allow.length} allowed\n`);
|
|
681
|
+
}
|
|
682
|
+
else if (command !== "fileop") {
|
|
683
|
+
process.stderr.write("usage: eve-sandbox-daemon serve [--config <path>] | fileop\n");
|
|
684
|
+
process.exit(2);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
//# sourceMappingURL=daemon.js.map
|