@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/LICENSE +235 -0
- package/README.md +46 -0
- package/dist/index.d.ts +364 -0
- package/dist/index.js +867 -0
- package/package.json +63 -0
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 };
|