@themoltnet/sandbox-gondolin 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/dist/index.js ADDED
@@ -0,0 +1,867 @@
1
+ import path, { isAbsolute, relative } from "node:path";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
5
+ import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, isWriteFlag, loadGuestAssets } from "@earendil-works/gondolin";
6
+ import { parseEnv } from "node:util";
7
+ //#region src/abort-utils.ts
8
+ function throwIfAborted(signal, label) {
9
+ if (!signal?.aborted) return;
10
+ throw abortError(label, signal);
11
+ }
12
+ function abortError(label, signal) {
13
+ const reason = signal.reason;
14
+ const suffix = reason instanceof Error ? reason.message : reason === void 0 ? "aborted" : String(reason);
15
+ const err = /* @__PURE__ */ new Error(`${label} aborted: ${suffix}`);
16
+ err.name = "AbortError";
17
+ return err;
18
+ }
19
+ function cleanupLateResource(resourcePromise, opts) {
20
+ resourcePromise.then(async (resource) => {
21
+ try {
22
+ await opts.cleanup(resource);
23
+ } catch (err) {
24
+ opts.onCleanupError?.(err);
25
+ }
26
+ }, () => {});
27
+ }
28
+ async function abortableResource(opts) {
29
+ const { signal } = opts;
30
+ if (!signal) return opts.promise;
31
+ throwIfAborted(signal, opts.label);
32
+ const resourcePromise = Promise.resolve(opts.promise);
33
+ const abortPromise = new Promise((_, reject) => {
34
+ const abort = () => {
35
+ cleanupLateResource(resourcePromise, opts);
36
+ reject(abortError(opts.label, signal));
37
+ };
38
+ signal.addEventListener("abort", abort, { once: true });
39
+ resourcePromise.then(() => signal.removeEventListener("abort", abort), () => signal.removeEventListener("abort", abort));
40
+ });
41
+ return Promise.race([resourcePromise, abortPromise]);
42
+ }
43
+ async function delay(ms, signal, label) {
44
+ if (!signal) {
45
+ await new Promise((resolve) => {
46
+ setTimeout(resolve, ms);
47
+ });
48
+ return;
49
+ }
50
+ throwIfAborted(signal, label);
51
+ await new Promise((resolve, reject) => {
52
+ const listener = () => {
53
+ clearTimeout(timeout);
54
+ reject(abortError(label, signal));
55
+ };
56
+ const timeout = setTimeout(() => {
57
+ signal.removeEventListener("abort", listener);
58
+ resolve();
59
+ }, ms);
60
+ signal.addEventListener("abort", listener, { once: true });
61
+ });
62
+ }
63
+ //#endregion
64
+ //#region src/path-containment.ts
65
+ /**
66
+ * Check containment for already-resolved lexical or real paths.
67
+ *
68
+ * Callers that accept untrusted paths must resolve/realpath at their I/O
69
+ * boundary first; keeping the platform-specific relative-path rule here avoids
70
+ * subtly different `..` and absolute-path handling across runtime cleanup,
71
+ * session sync, and artifact staging.
72
+ */
73
+ function isResolvedPathInsideRoot(path, root) {
74
+ const rel = relative(root, path);
75
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
76
+ }
77
+ //#endregion
78
+ //#region src/snapshot.ts
79
+ /**
80
+ * Snapshot builder with auto-build and caching.
81
+ *
82
+ * Builds a Gondolin VM snapshot in two layers:
83
+ * 1. Base (always): Alpine essentials, git, gh CLI, MoltNet CLI, agent user
84
+ * 2. User setup commands (optional): arbitrary shell commands on top of the base
85
+ *
86
+ * Consumers provide raw shell commands — no abstraction over package managers
87
+ * or runtimes. The base provides curl, git, tar, jq; everything else is up to
88
+ * the setup commands.
89
+ *
90
+ * Caches in a platform-appropriate directory:
91
+ * - macOS: ~/Library/Caches/moltnet/gondolin/
92
+ * - Linux: ~/.cache/moltnet/gondolin/
93
+ *
94
+ * The cache key is a hash of the full config. When any input changes,
95
+ * a new snapshot is built automatically.
96
+ */
97
+ /** Alpine packages whose commands are guaranteed in every snapshot. */
98
+ var BASE_ALPINE_PACKAGE_EXECUTABLES = {
99
+ curl: "curl",
100
+ file: "file",
101
+ git: "git",
102
+ jq: "jq",
103
+ ripgrep: "rg",
104
+ tar: "tar",
105
+ xz: "xz"
106
+ };
107
+ /** Alpine packages required by every snapshot. */
108
+ var BASE_ALPINE_PACKAGES = ["ca-certificates", ...Object.keys(BASE_ALPINE_PACKAGE_EXECUTABLES)];
109
+ /** Commands guaranteed by the base Gondolin snapshot. */
110
+ var GONDOLIN_BASE_EXECUTABLES = Object.freeze([
111
+ ...Object.values(BASE_ALPINE_PACKAGE_EXECUTABLES),
112
+ "gh",
113
+ "moltnet"
114
+ ].sort());
115
+ /** gh CLI version installed in every snapshot. */
116
+ var GH_VERSION = "2.74.0";
117
+ /** MoltNet CLI version — downloaded as a binary, no Node needed. */
118
+ var MOLTNET_CLI_VERSION = "1.37.0";
119
+ /**
120
+ * Resolve guest architecture from host (Gondolin VMs match host arch).
121
+ *
122
+ * The two naming conventions are NOT interchangeable:
123
+ * - `gh` — GitHub release-asset suffix (gh CLI ships `linux_amd64.tar.gz`,
124
+ * `linux_arm64.tar.gz`).
125
+ * - `npm` — npm optionalDependencies naming, which mirrors Node's
126
+ * `process.arch` values (`x64`, `arm64`). The MoltNet CLI is
127
+ * published as `@themoltnet/cli-linux-x64` and
128
+ * `@themoltnet/cli-linux-arm64`, NOT `cli-linux-amd64`.
129
+ */
130
+ function getGuestArch() {
131
+ if (process.arch === "arm64") return {
132
+ gh: "linux_arm64",
133
+ npm: "linux-arm64"
134
+ };
135
+ return {
136
+ gh: "linux_amd64",
137
+ npm: "linux-x64"
138
+ };
139
+ }
140
+ /** Hosts reachable during snapshot build. */
141
+ var SETUP_ALLOWED_HOSTS = [
142
+ "dl-cdn.alpinelinux.org",
143
+ "*.alpinelinux.org",
144
+ "registry.npmjs.org",
145
+ "*.npmjs.org",
146
+ "nodejs.org",
147
+ "*.nodejs.org",
148
+ "unofficial-builds.nodejs.org",
149
+ "github.com",
150
+ "*.github.com",
151
+ "*.githubusercontent.com",
152
+ "objects.githubusercontent.com"
153
+ ];
154
+ var DEFAULT_CONFIG = {};
155
+ function getCacheDir() {
156
+ if (process.platform === "darwin") return path.join(process.env.HOME ?? "/tmp", "Library", "Caches", "moltnet", "gondolin");
157
+ const base = process.env.XDG_CACHE_HOME ?? path.join(process.env.HOME ?? "/tmp", ".cache");
158
+ return path.join(base, "moltnet", "gondolin");
159
+ }
160
+ function computeConfigHash(config) {
161
+ const h = createHash("sha256");
162
+ h.update(JSON.stringify({
163
+ baseAlpine: BASE_ALPINE_PACKAGES,
164
+ ghVersion: GH_VERSION,
165
+ cliVersion: MOLTNET_CLI_VERSION,
166
+ config
167
+ }));
168
+ return h.digest("hex").slice(0, 12);
169
+ }
170
+ function getSnapshotPath(config) {
171
+ const hash = computeConfigHash(config);
172
+ const dir = path.join(getCacheDir(), `v2-${hash}`);
173
+ return path.join(dir, "snapshot.qcow2");
174
+ }
175
+ /**
176
+ * Ensure a cached snapshot exists, building one if needed.
177
+ * Returns the absolute path to the qcow2 checkpoint file.
178
+ */
179
+ async function ensureSnapshot(options = {}) {
180
+ const config = options.config ?? DEFAULT_CONFIG;
181
+ const log = options.onProgress ?? (() => {});
182
+ const maxCached = options.maxCached ?? 1;
183
+ const snapshotPath = getSnapshotPath(config);
184
+ const snapshotDir = path.dirname(snapshotPath);
185
+ if (existsSync(snapshotPath)) {
186
+ log(`snapshot cache hit: ${snapshotPath}`);
187
+ return snapshotPath;
188
+ }
189
+ log("snapshot cache miss — building (this takes 1-3 minutes)...");
190
+ mkdirSync(snapshotDir, { recursive: true });
191
+ const overlayPath = path.join(snapshotDir, "build.overlay.qcow2");
192
+ if (existsSync(overlayPath)) rmSync(overlayPath);
193
+ if (existsSync(snapshotPath)) rmSync(snapshotPath);
194
+ log("resolving alpine-base image...");
195
+ const assets = loadGuestAssets((await ensureImageSelector("alpine-base")).assetDir);
196
+ const overlaySize = config.overlaySize ?? "3G";
197
+ log(`creating qcow2 overlay (${overlaySize})...`);
198
+ execFileSync("qemu-img", [
199
+ "create",
200
+ "-f",
201
+ "qcow2",
202
+ "-F",
203
+ "raw",
204
+ "-b",
205
+ assets.rootfsPath,
206
+ overlayPath,
207
+ overlaySize
208
+ ], { stdio: "pipe" });
209
+ const { httpHooks } = createHttpHooks({ allowedHosts: [...SETUP_ALLOWED_HOSTS, ...config.allowedHosts ?? []] });
210
+ log("booting VM for setup...");
211
+ const vm = await VM.create({
212
+ httpHooks,
213
+ env: {
214
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
215
+ HOME: "/root",
216
+ GOROOT: "/usr/lib/go",
217
+ GOPATH: "/root/go"
218
+ },
219
+ sandbox: {
220
+ rootDiskPath: overlayPath,
221
+ rootDiskFormat: "qcow2",
222
+ rootDiskReadOnly: false,
223
+ rootDiskDeleteOnClose: false
224
+ }
225
+ });
226
+ try {
227
+ await buildSnapshot(vm, config, log);
228
+ log("creating checkpoint...");
229
+ await vm.checkpoint(snapshotPath);
230
+ log(`snapshot saved: ${snapshotPath}`);
231
+ } finally {
232
+ try {
233
+ await vm.close();
234
+ } catch {}
235
+ if (existsSync(overlayPath)) rmSync(overlayPath, { force: true });
236
+ }
237
+ pruneOldSnapshots(maxCached, snapshotDir);
238
+ return snapshotPath;
239
+ }
240
+ /** Helper: run a command in the VM, throw on failure. */
241
+ async function run(vm, log, label, cmd) {
242
+ log(label);
243
+ const r = await vm.exec(cmd);
244
+ if (r.exitCode !== 0) {
245
+ const output = [r.stderr, r.stdout].filter(Boolean).join("\n").slice(0, 800);
246
+ throw new Error(`snapshot build "${label}" failed (exit ${r.exitCode}):\n${output}`);
247
+ }
248
+ }
249
+ async function buildSnapshot(vm, config, log) {
250
+ await run(vm, log, "resizing rootfs...", "apk add --no-cache e2fsprogs-extra >/dev/null 2>&1 && resize2fs /dev/vda 2>/dev/null");
251
+ await run(vm, log, `installing base packages: ${BASE_ALPINE_PACKAGES.join(" ")}`, `apk add --no-cache ${BASE_ALPINE_PACKAGES.join(" ")}`);
252
+ const arch = getGuestArch();
253
+ await run(vm, log, `installing gh ${GH_VERSION} (${arch.gh})...`, `sh -eu -c '
254
+ curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_${arch.gh}.tar.gz" -o /tmp/gh.tar.gz
255
+ tar -xzf /tmp/gh.tar.gz -C /tmp
256
+ mv /tmp/gh_${GH_VERSION}_${arch.gh}/bin/gh /usr/local/bin/gh
257
+ chmod +x /usr/local/bin/gh
258
+ rm -rf /tmp/gh.tar.gz /tmp/gh_${GH_VERSION}_${arch.gh}
259
+ gh --version
260
+ '`);
261
+ await run(vm, log, `installing moltnet CLI ${MOLTNET_CLI_VERSION} (${arch.npm})...`, `sh -eu -c '
262
+ curl -fsSL "https://registry.npmjs.org/@themoltnet/cli-${arch.npm}/-/cli-${arch.npm}-${MOLTNET_CLI_VERSION}.tgz" -o /tmp/moltnet.tgz
263
+ tar -xzf /tmp/moltnet.tgz -C /tmp
264
+ mv /tmp/package/bin/moltnet /usr/local/bin/moltnet
265
+ chmod +x /usr/local/bin/moltnet
266
+ rm -rf /tmp/moltnet.tgz /tmp/package
267
+ '`);
268
+ await run(vm, log, "creating agent user...", `sh -eu -c '
269
+ addgroup -g 501 agent 2>/dev/null || true
270
+ adduser -D -u 501 -G agent -h /home/agent -s /bin/sh agent 2>/dev/null || true
271
+ mkdir -p /home/agent/.moltnet /home/agent/.cache
272
+ chown -R agent:agent /home/agent
273
+ chmod 644 /etc/gondolin/mitm/ca.crt 2>/dev/null || true
274
+ '`);
275
+ await run(vm, log, "configuring DNS resolvers...", `sh -c 'echo "nameserver 8.8.8.8
276
+ nameserver 1.1.1.1" > /etc/resolv.conf'`);
277
+ if (config.setupCommands?.length) for (let i = 0; i < config.setupCommands.length; i++) await run(vm, log, `setup [${i + 1}/${config.setupCommands.length}]...`, config.setupCommands[i]);
278
+ }
279
+ function pruneOldSnapshots(maxCached, currentDir) {
280
+ const cacheRoot = getCacheDir();
281
+ if (!existsSync(cacheRoot)) return;
282
+ const entries = readdirSync(cacheRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("v")).map((e) => {
283
+ const fullPath = path.join(cacheRoot, e.name);
284
+ return {
285
+ path: fullPath,
286
+ mtime: statSync(fullPath).mtimeMs
287
+ };
288
+ }).sort((a, b) => b.mtime - a.mtime);
289
+ for (const entry of entries.slice(maxCached + 1)) if (entry.path !== currentDir) rmSync(entry.path, {
290
+ recursive: true,
291
+ force: true
292
+ });
293
+ }
294
+ //#endregion
295
+ //#region src/vm-manager.ts
296
+ /**
297
+ * Memory-backed VFS mount used by the daemon to inject task context
298
+ * (#943 slice 1.5). This is a separate top-level mount because Gondolin
299
+ * mounts can't nest. The agent's Gondolin-bound Read tool accepts paths
300
+ * under this prefix (see toGuestPath in tool-operations.ts).
301
+ *
302
+ * Why MemoryProvider rather than a path under the workspace mount:
303
+ * - Injected task context is ephemeral by intent: per-task-attempt input
304
+ * scoped to the VM lifetime. MemoryProvider models that exactly —
305
+ * in-memory, per-VM-instance, zero host artefacts, automatic
306
+ * cleanup on VM close.
307
+ * - Writing under the workspace mount fails in worktrees because we symlink
308
+ * `.moltnet/` to the main repo (so credentials are reachable from
309
+ * worktrees), and Gondolin's RealFSProvider correctly refuses to
310
+ * create paths whose ancestors' realpath escapes the mount root.
311
+ * That refusal is a deliberate sandbox-escape protection, not a
312
+ * bug. See diary semantic entry cd27d9d3-efdc-4aec-ac0d-5fd8ce258d1f
313
+ * and episodic 7affbfeb-18a2-4963-aeac-c177eb2afa2d for the full
314
+ * investigation and the alternatives we rejected.
315
+ */
316
+ var GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
317
+ /** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
318
+ var GUEST_TASK_SKILLS_MOUNT = GUEST_TASK_CONTEXT_MOUNT;
319
+ function resolveVfsShadowConfig(config) {
320
+ const patterns = config?.vfs?.shadow ?? [];
321
+ if (patterns.length === 0) return {
322
+ mode: "none",
323
+ patterns: []
324
+ };
325
+ return {
326
+ mode: config?.vfs?.shadowMode ?? "tmpfs",
327
+ patterns
328
+ };
329
+ }
330
+ function shouldRunResumeCommand(entry, ctx) {
331
+ if (typeof entry === "string") return true;
332
+ const workspaceModes = entry.when?.workspaceMode;
333
+ if (workspaceModes && !workspaceModes.includes(ctx.workspaceMode)) return false;
334
+ return true;
335
+ }
336
+ function shouldShadowNodeModulesPath(pathname) {
337
+ const normalized = path.posix.normalize(pathname);
338
+ return normalized === "/node_modules" || normalized.startsWith("/node_modules/") || normalized.endsWith("/node_modules") || normalized.includes("/node_modules/");
339
+ }
340
+ function isNodeModulesBinPath(pathname) {
341
+ const normalized = path.posix.normalize(pathname);
342
+ return normalized.includes("/node_modules/.bin/") || normalized.startsWith("/node_modules/.bin/");
343
+ }
344
+ var AutoParentMemoryProvider = class extends MemoryProvider {
345
+ ensureParentDir(pathname) {
346
+ const parent = path.posix.dirname(path.posix.normalize(pathname));
347
+ if (!parent || parent === "/" || parent === ".") return;
348
+ this.mkdirSync(parent, { recursive: true });
349
+ }
350
+ async mkdir(pathname, options) {
351
+ this.ensureParentDir(pathname);
352
+ return super.mkdir(pathname, options);
353
+ }
354
+ mkdirSync(pathname, options) {
355
+ this.ensureParentDir(pathname);
356
+ return super.mkdirSync(pathname, options);
357
+ }
358
+ async open(pathname, flags, mode) {
359
+ if (isWriteFlag(flags)) this.ensureParentDir(pathname);
360
+ return super.open(pathname, flags, isWriteFlag(flags) && isNodeModulesBinPath(pathname) ? (mode ?? 493) | 73 : mode);
361
+ }
362
+ openSync(pathname, flags, mode) {
363
+ if (isWriteFlag(flags)) this.ensureParentDir(pathname);
364
+ return super.openSync(pathname, flags, isWriteFlag(flags) && isNodeModulesBinPath(pathname) ? (mode ?? 493) | 73 : mode);
365
+ }
366
+ };
367
+ /**
368
+ * Resolve the main worktree root (where .moltnet/ lives — it's untracked,
369
+ * only exists in the main worktree, not in git worktrees).
370
+ */
371
+ function findMainWorktree(startPath = process.cwd()) {
372
+ let output;
373
+ try {
374
+ output = execFileSync("git", [
375
+ "-C",
376
+ startPath,
377
+ "worktree",
378
+ "list",
379
+ "--porcelain"
380
+ ], {
381
+ encoding: "utf8",
382
+ stdio: "pipe"
383
+ });
384
+ } catch (err) {
385
+ const message = err instanceof Error ? err.message : String(err);
386
+ throw new Error(`Git worktree discovery requires a git repository: ${message}`);
387
+ }
388
+ for (const block of output.split("\n\n")) {
389
+ const lines = block.split("\n");
390
+ const wt = lines.find((l) => l.startsWith("worktree "));
391
+ if (wt && !lines.some((l) => l === "bare")) return wt.replace("worktree ", "");
392
+ }
393
+ throw new Error("Could not find main git worktree");
394
+ }
395
+ function resolveVmAgentDir(config) {
396
+ const rootDir = config.agentRootDir ?? findMainWorktree();
397
+ return path.join(rootDir, ".moltnet", config.agentName);
398
+ }
399
+ function loadCredentials(agentDir, mode = "guest-config", onDiagnostic, providerAuth) {
400
+ const moltnetPath = path.join(agentDir, "moltnet.json");
401
+ const agentEnvPath = path.join(agentDir, "env");
402
+ const providerAuthJson = providerAuth?.load() ?? null;
403
+ if (mode === "host-authenticated") return {
404
+ moltnetJson: "",
405
+ agentEnvRaw: "",
406
+ providerAuthJson,
407
+ agentEnv: {},
408
+ gitconfig: null,
409
+ sshPrivateKey: null,
410
+ sshPublicKey: null,
411
+ allowedSigners: null,
412
+ githubAppPem: null,
413
+ githubAppPemFilename: null
414
+ };
415
+ const hasMoltnetJson = existsSync(moltnetPath);
416
+ const hasAgentEnv = existsSync(agentEnvPath);
417
+ if (!hasMoltnetJson || !hasAgentEnv) throw new Error(`Guest credential mode requires both ${moltnetPath} and ${agentEnvPath}`);
418
+ const moltnetJson = readFileSync(moltnetPath, "utf8");
419
+ const agentEnvRaw = readFileSync(agentEnvPath, "utf8");
420
+ if (moltnetJson.trim() === "") throw new Error(`Agent configuration is empty: ${moltnetPath}`);
421
+ const gitconfigPath = path.join(agentDir, "gitconfig");
422
+ const gitconfig = existsSync(gitconfigPath) ? readFileSync(gitconfigPath, "utf8") : null;
423
+ const sshDir = path.join(agentDir, "ssh");
424
+ const sshPrivateKey = existsSync(path.join(sshDir, "id_ed25519")) ? readFileSync(path.join(sshDir, "id_ed25519"), "utf8") : null;
425
+ const sshPublicKey = existsSync(path.join(sshDir, "id_ed25519.pub")) ? readFileSync(path.join(sshDir, "id_ed25519.pub"), "utf8") : null;
426
+ const allowedSigners = existsSync(path.join(sshDir, "allowed_signers")) ? readFileSync(path.join(sshDir, "allowed_signers"), "utf8") : null;
427
+ let githubAppPem = null;
428
+ let githubAppPemFilename = null;
429
+ const pemPath = (moltnetJson ? JSON.parse(moltnetJson) : null)?.github?.private_key_path;
430
+ if (pemPath) if (!existsSync(pemPath)) onDiagnostic?.({
431
+ event: "vm.credentials.github_key_missing",
432
+ level: "warning",
433
+ credentialMode: mode,
434
+ message: `github.private_key_path not found at ${pemPath}; moltnet github token will fail inside the guest`
435
+ });
436
+ else {
437
+ githubAppPem = readFileSync(pemPath, "utf8");
438
+ githubAppPemFilename = path.basename(pemPath);
439
+ }
440
+ return {
441
+ moltnetJson,
442
+ agentEnvRaw,
443
+ providerAuthJson,
444
+ agentEnv: parseEnv(agentEnvRaw),
445
+ gitconfig,
446
+ sshPrivateKey,
447
+ sshPublicKey,
448
+ allowedSigners,
449
+ githubAppPem,
450
+ githubAppPemFilename
451
+ };
452
+ }
453
+ /**
454
+ * Apply agent env vars to the host process, mirroring `moltnet start`.
455
+ * Resolves relative paths (e.g. GIT_CONFIG_GLOBAL) against the repo root.
456
+ */
457
+ function activateAgentEnv(agentEnv, repoRoot) {
458
+ for (const [k, v] of Object.entries(agentEnv)) {
459
+ if (v === void 0 || v === null || v === "") continue;
460
+ let resolved = v;
461
+ if (k === "GIT_CONFIG_GLOBAL" && !path.isAbsolute(v)) resolved = path.join(repoRoot, v);
462
+ process.env[k] = resolved;
463
+ }
464
+ }
465
+ var BASE_ALLOWED_HOSTS = [
466
+ "api.openai.com",
467
+ "*.openai.com",
468
+ "chat.openai.com",
469
+ "chatgpt.com",
470
+ "*.chatgpt.com",
471
+ "registry.npmjs.org",
472
+ "github.com",
473
+ "*.github.com",
474
+ "*.githubusercontent.com",
475
+ "proxy.golang.org",
476
+ "sum.golang.org",
477
+ "golang.org",
478
+ "storage.googleapis.com",
479
+ "*.googlesource.com"
480
+ ];
481
+ var DEFAULT_MOLTNET_API_URL = "https://api.themolt.net";
482
+ /**
483
+ * Host environment names that may intentionally cross into a
484
+ * host-authenticated guest. This local list is the authority boundary;
485
+ * server-supplied runtime-profile `requiredEnv` cannot widen it.
486
+ */
487
+ var HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST = new Set([
488
+ "ANTHROPIC_API_KEY",
489
+ "OPENAI_API_KEY",
490
+ "OPENAI_BASE_URL",
491
+ "AZURE_OPENAI_API_KEY",
492
+ "AZURE_OPENAI_ENDPOINT",
493
+ "AZURE_OPENAI_API_VERSION",
494
+ "GOOGLE_API_KEY",
495
+ "GEMINI_API_KEY",
496
+ "MISTRAL_API_KEY",
497
+ "GROQ_API_KEY",
498
+ "OPENROUTER_API_KEY",
499
+ "XAI_API_KEY",
500
+ "CEREBRAS_API_KEY",
501
+ "DEEPSEEK_API_KEY",
502
+ "OLLAMA_API_KEY",
503
+ "OLLAMA_BASE_URL",
504
+ "AWS_ACCESS_KEY_ID",
505
+ "AWS_SECRET_ACCESS_KEY",
506
+ "AWS_SESSION_TOKEN",
507
+ "AWS_REGION",
508
+ "AWS_DEFAULT_REGION",
509
+ "GITHUB_TOKEN",
510
+ "GH_TOKEN",
511
+ "LINEAR_API_KEY"
512
+ ]);
513
+ var RESERVED_GUEST_ENVIRONMENT_NAMES = new Set([
514
+ "PATH",
515
+ "HOME",
516
+ "NODE_EXTRA_CA_CERTS",
517
+ "MOLTNET_GUEST_WORKSPACE",
518
+ "GIT_SSH",
519
+ "GIT_SSH_COMMAND",
520
+ "SSH_AUTH_SOCK"
521
+ ]);
522
+ function isReservedGuestEnvironmentName(name) {
523
+ return name.startsWith("MOLTNET_") || name.startsWith("GIT_CONFIG_") || RESERVED_GUEST_ENVIRONMENT_NAMES.has(name);
524
+ }
525
+ var GuestEnvironmentBoundaryError = class extends Error {
526
+ constructor(refusedNames) {
527
+ super(`Guest credential boundary refuses runtime-controlled environment variables: ${refusedNames.join(", ")}. Remove them from the runtime profile; MoltNet operations use the trusted host-side Agent.`);
528
+ this.refusedNames = refusedNames;
529
+ this.name = "GuestEnvironmentBoundaryError";
530
+ }
531
+ };
532
+ function assertGuestEnvironmentBoundary(options) {
533
+ const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || options.guestCredentialMode === "host-authenticated" && !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
534
+ const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
535
+ const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
536
+ if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
537
+ }
538
+ /** @deprecated Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
539
+ function assertHostAuthenticatedGuestEnvironment(options) {
540
+ assertGuestEnvironmentBoundary({
541
+ guestCredentialMode: "host-authenticated",
542
+ ...options
543
+ });
544
+ }
545
+ /**
546
+ * Return whether two Gondolin hostname globs can match at least one common
547
+ * string. Each `*` is an arbitrary substring, so this walks the product of the
548
+ * two small glob automata instead of relying on exact-string comparisons.
549
+ */
550
+ function hostnamePatternsOverlap(left, right) {
551
+ const a = left.trim().toLowerCase();
552
+ const b = right.trim().toLowerCase();
553
+ if (!a || !b) return false;
554
+ const pending = [[0, 0]];
555
+ const visited = /* @__PURE__ */ new Set();
556
+ while (pending.length > 0) {
557
+ const next = pending.pop();
558
+ if (!next) continue;
559
+ const [aIndex, bIndex] = next;
560
+ const state = `${aIndex}:${bIndex}`;
561
+ if (visited.has(state)) continue;
562
+ visited.add(state);
563
+ if (aIndex === a.length && bIndex === b.length) return true;
564
+ const aChar = a[aIndex];
565
+ const bChar = b[bIndex];
566
+ if (aChar === "*") pending.push([aIndex + 1, bIndex]);
567
+ if (bChar === "*") pending.push([aIndex, bIndex + 1]);
568
+ if (aChar !== void 0 && bChar !== void 0 && (aChar === "*" || bChar === "*" || aChar === bChar)) pending.push([aChar === "*" ? aIndex : aIndex + 1, bChar === "*" ? bIndex : bIndex + 1]);
569
+ }
570
+ return false;
571
+ }
572
+ function assertInternalHostsDoNotOverlapProtectedHosts(internalHosts, protectedHosts) {
573
+ for (const internalHost of internalHosts) {
574
+ const protectedHost = protectedHosts.find((candidate) => hostnamePatternsOverlap(internalHost, candidate));
575
+ if (protectedHost) throw new Error(`sandbox.network.allowedInternalHosts pattern "${internalHost}" overlaps external-only host pattern "${protectedHost}"`);
576
+ }
577
+ }
578
+ /**
579
+ * Run a shell command in the guest and throw if it fails. Mirror of
580
+ * `run()` in `snapshot.ts` for the resume-side hook chain — every
581
+ * setup step is essential to a healthy session, so a silent non-zero
582
+ * exit (e.g. a mount that fails into the FUSE write path, or a
583
+ * consumer-provided resume command that fails to install pnpm) must
584
+ * surface immediately rather than fall through to cryptic agent
585
+ * errors later.
586
+ */
587
+ async function vmRun(vm, label, command, signal) {
588
+ const wrapped = `set -eu\nset -o pipefail\n${command}`;
589
+ throwIfAborted(signal, `resume step "${label}"`);
590
+ const r = await vm.exec([
591
+ "sh",
592
+ "-c",
593
+ wrapped
594
+ ], { signal });
595
+ if (r.exitCode !== 0) {
596
+ const tail = [r.stderr, r.stdout].filter(Boolean).join("\n").slice(-800);
597
+ throw new Error(`resume step "${label}" failed (exit ${r.exitCode}):\n${tail}`);
598
+ }
599
+ }
600
+ function nonErrorMessage(err) {
601
+ if (typeof err === "string") return err;
602
+ try {
603
+ return JSON.stringify(err) ?? "unknown error";
604
+ } catch {
605
+ return "unknown error";
606
+ }
607
+ }
608
+ /**
609
+ * Resume a VM from a checkpoint, inject credentials, configure egress +
610
+ * TLS. Returns the managed VM handle.
611
+ */
612
+ async function resumeVm(config) {
613
+ throwIfAborted(config.signal, "VM resume");
614
+ const agentDir = resolveVmAgentDir(config);
615
+ const guestWorkspace = path.resolve(config.mountPath);
616
+ const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
617
+ if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
618
+ assertGuestEnvironmentBoundary({
619
+ guestCredentialMode,
620
+ forwardEnv: config.forwardEnv,
621
+ sandboxEnv: config.sandboxConfig?.env
622
+ });
623
+ config.onDiagnostic?.({
624
+ event: "vm.credentials.mode",
625
+ level: "info",
626
+ credentialMode: guestCredentialMode,
627
+ message: guestCredentialMode === "host-authenticated" ? "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest" : "The complete MoltNet agent configuration is available to the guest"
628
+ });
629
+ const creds = loadCredentials(agentDir, guestCredentialMode, config.onDiagnostic, config.providerAuth);
630
+ const configuredApiUrl = creds.moltnetJson ? JSON.parse(creds.moltnetJson).endpoints.api : void 0;
631
+ const apiHost = new URL(configuredApiUrl ?? process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
632
+ const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
633
+ const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
634
+ const protectedExternalHosts = [...new Set([
635
+ ...BASE_ALLOWED_HOSTS,
636
+ apiHost,
637
+ ...config.extraAllowedHosts ?? []
638
+ ])];
639
+ assertInternalHostsDoNotOverlapProtectedHosts(runtimeAllowedInternalHosts, protectedExternalHosts);
640
+ const { httpHooks, env: secretEnv } = createHttpHooks({
641
+ allowedHosts: [...new Set([...protectedExternalHosts, ...runtimeAllowedHosts])],
642
+ allowedInternalHosts: runtimeAllowedInternalHosts
643
+ });
644
+ const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
645
+ const vmAgentEnv = {};
646
+ for (const [k, v] of Object.entries(creds.agentEnv)) {
647
+ if (v === void 0 || v === "") continue;
648
+ if (k === "GIT_CONFIG_GLOBAL") vmAgentEnv[k] = `${vmAgentDir}/gitconfig`;
649
+ else if (k.endsWith("_PRIVATE_KEY_PATH")) vmAgentEnv[k] = `${vmAgentDir}/${path.basename(v)}`;
650
+ else vmAgentEnv[k] = v;
651
+ }
652
+ if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
653
+ const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
654
+ let workspaceProvider = new RealFSProvider(config.mountPath);
655
+ workspaceProvider = new ShadowProvider(workspaceProvider, {
656
+ shouldShadow: ({ path: shadowPath }) => shouldShadowNodeModulesPath(shadowPath),
657
+ denySymlinkBypass: false,
658
+ tmpfs: new AutoParentMemoryProvider(),
659
+ writeMode: "tmpfs"
660
+ });
661
+ if (vfsConfig.mode !== "none") {
662
+ const predicate = createShadowPathPredicate(vfsConfig.patterns);
663
+ workspaceProvider = new ShadowProvider(workspaceProvider, {
664
+ shouldShadow: predicate,
665
+ writeMode: vfsConfig.mode
666
+ });
667
+ }
668
+ if (guestCredentialMode === "host-authenticated") workspaceProvider = new ShadowProvider(workspaceProvider, {
669
+ shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
670
+ denySymlinkBypass: true,
671
+ writeMode: "deny"
672
+ });
673
+ const forwardedEnv = {};
674
+ for (const name of config.forwardEnv ?? []) {
675
+ const value = process.env[name];
676
+ if (value === void 0 || value === "") continue;
677
+ forwardedEnv[name] = value;
678
+ }
679
+ const envOverrides = config.sandboxConfig?.env ?? {};
680
+ const vmEnv = {
681
+ ...secretEnv,
682
+ ...vmAgentEnv,
683
+ ...forwardedEnv,
684
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
685
+ HOME: "/home/agent",
686
+ NODE_NO_WARNINGS: "1",
687
+ NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
688
+ ...envOverrides,
689
+ MOLTNET_GUEST_WORKSPACE: guestWorkspace
690
+ };
691
+ const resources = config.sandboxConfig?.resources;
692
+ const workspaceMode = config.workspaceMode ?? "shared_mount";
693
+ const vm = await abortableResource({
694
+ promise: VmCheckpoint.load(config.checkpointPath).resume({
695
+ httpHooks,
696
+ env: vmEnv,
697
+ ...resources?.memory && { memory: resources.memory },
698
+ ...resources?.cpus && { cpus: resources.cpus },
699
+ vfs: { mounts: {
700
+ [guestWorkspace]: workspaceProvider,
701
+ [GUEST_TASK_CONTEXT_MOUNT]: new MemoryProvider()
702
+ } }
703
+ }),
704
+ signal: config.signal,
705
+ label: "VM resume",
706
+ cleanup: (resumedVm) => resumedVm.close(),
707
+ onCleanupError: (err) => {
708
+ const message = err instanceof Error ? err.message : String(err);
709
+ process.stderr.write(`[vm] aborted resume late vm.close() failed: ${message}\n`);
710
+ }
711
+ });
712
+ try {
713
+ await vmRun(vm, "TLS certificates", `
714
+ cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
715
+ update-ca-certificates 2>/dev/null
716
+ cat /etc/gondolin/mitm/ca.crt >> /etc/ssl/certs/ca-certificates.crt
717
+ `, config.signal);
718
+ await vmRun(vm, "DNS resolvers", `printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf`, config.signal);
719
+ await vmRun(vm, "git safe.directory", `git config --system --add safe.directory '*'`, config.signal);
720
+ for (const [i, entry] of (config.sandboxConfig?.resumeCommands ?? []).entries()) {
721
+ if (!shouldRunResumeCommand(entry, { workspaceMode })) continue;
722
+ const { run, retries, backoffMs } = typeof entry === "string" ? {
723
+ run: entry,
724
+ retries: 0,
725
+ backoffMs: 2e3
726
+ } : {
727
+ run: entry.run,
728
+ retries: entry.retries ?? 0,
729
+ backoffMs: entry.retryBackoffMs ?? 2e3
730
+ };
731
+ const label = `resumeCommands[${i}]`;
732
+ let lastErr;
733
+ for (let attempt = 0; attempt <= retries; attempt++) try {
734
+ await vmRun(vm, label, run, config.signal);
735
+ lastErr = void 0;
736
+ break;
737
+ } catch (err) {
738
+ lastErr = err;
739
+ if (attempt === retries) break;
740
+ await delay((attempt + 1) * backoffMs, config.signal, label);
741
+ }
742
+ if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
743
+ }
744
+ const vmSshDir = `${vmAgentDir}/ssh`;
745
+ const hasAgentFiles = guestCredentialMode === "guest-config";
746
+ const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
747
+ const guestDirs = [...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [], ...providerAuthDir ? [providerAuthDir] : []];
748
+ if (guestDirs.length > 0) await vm.exec(`mkdir -p ${guestDirs.join(" ")}`, { signal: config.signal });
749
+ if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
750
+ mode: 384,
751
+ signal: config.signal
752
+ });
753
+ if (hasAgentFiles) {
754
+ const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
755
+ await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
756
+ mode: 384,
757
+ signal: config.signal
758
+ });
759
+ await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, {
760
+ mode: 384,
761
+ signal: config.signal
762
+ });
763
+ if (creds.gitconfig) {
764
+ const vmGitconfig = rewriteGitconfigPaths(creds.gitconfig, vmSshDir, vmAgentDir);
765
+ await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, {
766
+ mode: 420,
767
+ signal: config.signal
768
+ });
769
+ }
770
+ if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
771
+ mode: 384,
772
+ signal: config.signal
773
+ });
774
+ if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
775
+ mode: 420,
776
+ signal: config.signal
777
+ });
778
+ if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
779
+ mode: 420,
780
+ signal: config.signal
781
+ });
782
+ if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
783
+ mode: 384,
784
+ signal: config.signal
785
+ });
786
+ }
787
+ await vm.exec(hasAgentFiles ? "chown -R agent:agent /home/agent/.pi /home/agent/.moltnet" : "chown -R agent:agent /home/agent/.pi", { signal: config.signal });
788
+ return {
789
+ vm,
790
+ credentials: creds,
791
+ mountPath: config.mountPath,
792
+ guestWorkspace,
793
+ agentDir
794
+ };
795
+ } catch (err) {
796
+ try {
797
+ await vm.close();
798
+ } catch (closeErr) {
799
+ const m = closeErr instanceof Error ? closeErr.message : String(closeErr);
800
+ process.stderr.write(`[vm] post-throw vm.close() failed: ${m}\n`);
801
+ }
802
+ throw err;
803
+ }
804
+ }
805
+ /**
806
+ * Rewrite host-absolute paths inside an agent gitconfig to VM-local
807
+ * equivalents before injecting it into the guest.
808
+ *
809
+ * Two rewrites:
810
+ * - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
811
+ * - `... credential-helper --credentials <host moltnet.json>`
812
+ * → `<vmAgentDir>/moltnet.json`
813
+ *
814
+ * The credential-helper line is generated host-side by `moltnet github setup`
815
+ * with a host-absolute `--credentials` path; inside the guest that path is
816
+ * invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
817
+ * rewrite rule and every other line are workspace-independent and pass through
818
+ * unchanged. A gitconfig without a credential helper is rewritten only for
819
+ * `signingKey`.
820
+ *
821
+ * This is the single source of truth for git push auth in the guest: the
822
+ * injected gitconfig carries the tokenless mint-on-demand helper, so the VM
823
+ * no longer hand-rolls a credential-helper script or runs an imperative
824
+ * `git config --global ... insteadOf` against the guest $HOME.
825
+ */
826
+ function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
827
+ return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
828
+ }
829
+ /**
830
+ * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
831
+ *
832
+ * Fields rewritten:
833
+ * ssh.private_key_path → <vmSshDir>/<basename of original>
834
+ * ssh.public_key_path → <vmSshDir>/<basename of original>
835
+ * git.config_path → <vmAgentDir>/gitconfig
836
+ * github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
837
+ *
838
+ * All other fields are passed through unchanged.
839
+ * Throws if moltnetJson is not valid JSON — callers must not inject a broken
840
+ * moltnet.json into the guest.
841
+ */
842
+ function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
843
+ const config = JSON.parse(moltnetJson);
844
+ if (config.ssh && typeof config.ssh === "object") {
845
+ const ssh = config.ssh;
846
+ const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
847
+ const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
848
+ config.ssh = {
849
+ ...ssh,
850
+ ...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
851
+ ...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
852
+ };
853
+ }
854
+ if (config.git && typeof config.git === "object") {
855
+ const git = { ...config.git };
856
+ git.config_path = `${vmAgentDir}/gitconfig`;
857
+ config.git = git;
858
+ }
859
+ if (githubAppPemFilename && config.github && typeof config.github === "object") {
860
+ const github = { ...config.github };
861
+ github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
862
+ config.github = github;
863
+ }
864
+ return JSON.stringify(config);
865
+ }
866
+ //#endregion
867
+ export { AutoParentMemoryProvider, GONDOLIN_BASE_EXECUTABLES, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, rewriteGitconfigPaths, rewriteMoltnetJsonPaths, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };