@themoltnet/sandbox-gondolin 0.3.0 → 0.4.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/README.md +7 -9
- package/dist/index.d.ts +76 -80
- package/dist/index.js +215 -210
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,10 +14,9 @@ implementation can be added beside it without going through Pi.
|
|
|
14
14
|
with VFS shadowing (`deny` / `tmpfs`), egress allowlist through Gondolin's
|
|
15
15
|
host-side HTTP hooks, explicit guest environment, resource limits, resume
|
|
16
16
|
commands, and abort-safe cleanup.
|
|
17
|
-
- The guest credential boundary:
|
|
18
|
-
`.moltnet/<agent>` tree
|
|
19
|
-
|
|
20
|
-
(`assertGuestEnvironmentBoundary`).
|
|
17
|
+
- The guest credential boundary: the guest never receives the
|
|
18
|
+
`.moltnet/<agent>` tree, and `MOLTNET_*` names are refused in
|
|
19
|
+
runtime-controlled guest environment (`assertGuestEnvironmentBoundary`).
|
|
21
20
|
- `VmConfig.brokeredSecrets` — host-brokered secrets: the guest sees a
|
|
22
21
|
placeholder, Gondolin's `SecretManager` substitutes the value only in
|
|
23
22
|
requests to the declared protocol, host patterns, and ports.
|
|
@@ -27,10 +26,10 @@ implementation can be added beside it without going through Pi.
|
|
|
27
26
|
|
|
28
27
|
## What it deliberately does not own
|
|
29
28
|
|
|
30
|
-
- **Provider authentication.**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
- **Provider authentication.** The coding-agent session and its model calls run
|
|
30
|
+
host-side (Pi's `createAgentSession` reads the host `~/.pi/agent` auth), so no
|
|
31
|
+
provider auth is projected into the guest — the guest only executes tools via
|
|
32
|
+
`vm.exec`.
|
|
34
33
|
- Tool definitions, tool policy, task execution, workspace preparation —
|
|
35
34
|
all Pi-runtime concerns.
|
|
36
35
|
|
|
@@ -54,7 +53,6 @@ contain a value or choose a host secret-provider coordinate.
|
|
|
54
53
|
const managed = await resumeVm({
|
|
55
54
|
checkpointPath,
|
|
56
55
|
agentName: 'worker',
|
|
57
|
-
guestCredentialMode: 'host-authenticated',
|
|
58
56
|
mountPath: workspace,
|
|
59
57
|
sandboxConfig: {
|
|
60
58
|
network: { allowedHosts: ['api.example.com'] },
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HttpIpAllowInfo } from '@earendil-works/gondolin';
|
|
1
2
|
import { MemoryProvider } from '@earendil-works/gondolin';
|
|
2
3
|
import { SecretDefinition } from '@earendil-works/gondolin';
|
|
3
4
|
import { VirtualFileHandle } from '@earendil-works/gondolin';
|
|
@@ -20,12 +21,11 @@ declare interface AbortableResourceOptions<T> {
|
|
|
20
21
|
export declare function activateAgentEnv(agentEnv: Record<string, string | undefined>, repoRoot: string): void;
|
|
21
22
|
|
|
22
23
|
export declare function assertGuestEnvironmentBoundary(options: {
|
|
23
|
-
guestCredentialMode: GuestCredentialMode;
|
|
24
24
|
forwardEnv?: readonly string[];
|
|
25
25
|
sandboxEnv?: Readonly<Record<string, string>>;
|
|
26
26
|
}): void;
|
|
27
27
|
|
|
28
|
-
/** @deprecated
|
|
28
|
+
/** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
|
|
29
29
|
export declare function assertHostAuthenticatedGuestEnvironment(options: {
|
|
30
30
|
forwardEnv?: readonly string[];
|
|
31
31
|
sandboxEnv?: Readonly<Record<string, string>>;
|
|
@@ -85,6 +85,20 @@ export declare interface BrokeredHttpSecretManager {
|
|
|
85
85
|
*/
|
|
86
86
|
export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: BrokeredHttpSecretDescriptor): Required<BrokeredHttpSecretDescriptor>;
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Refine Gondolin's hostname allowlist for hosts carrying brokered
|
|
90
|
+
* credentials. The built-in allowlist is hostname-granular; these callbacks
|
|
91
|
+
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
92
|
+
*/
|
|
93
|
+
export declare function createBrokeredHttpNetworkOriginPolicy(bindings: readonly BrokeredHttpSecretDescriptor[], options?: {
|
|
94
|
+
onDenied?: (denial: RequestOrigin & {
|
|
95
|
+
phase: 'request' | 'ip';
|
|
96
|
+
}) => void;
|
|
97
|
+
}): {
|
|
98
|
+
isRequestAllowed: (request: Request) => boolean;
|
|
99
|
+
isIpAllowed: (info: HttpIpAllowInfo) => boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
export declare function delay(ms: number, signal: AbortSignal | undefined, label: string): Promise<void>;
|
|
89
103
|
|
|
90
104
|
/**
|
|
@@ -100,6 +114,14 @@ export declare interface EnsureSnapshotOptions {
|
|
|
100
114
|
maxCached?: number;
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
119
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
120
|
+
* host API before returning, because no guest process/session boundary can
|
|
121
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
122
|
+
*/
|
|
123
|
+
export declare function execManagedCommand(vm: VM, command: string, options?: ManagedExecOptions): Promise<ManagedExecResult>;
|
|
124
|
+
|
|
103
125
|
/**
|
|
104
126
|
* Resolve the main worktree root (where .moltnet/ lives — it's untracked,
|
|
105
127
|
* only exists in the main worktree, not in git worktrees).
|
|
@@ -137,8 +159,6 @@ export declare const GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
|
|
|
137
159
|
/** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
|
|
138
160
|
export declare const GUEST_TASK_SKILLS_MOUNT = "/moltnet-task-context";
|
|
139
161
|
|
|
140
|
-
export declare type GuestCredentialMode = 'guest-config' | 'host-authenticated';
|
|
141
|
-
|
|
142
162
|
export declare class GuestEnvironmentBoundaryError extends Error {
|
|
143
163
|
readonly refusedNames: readonly string[];
|
|
144
164
|
constructor(refusedNames: readonly string[]);
|
|
@@ -187,7 +207,43 @@ declare type HostOriginHandler = (request: Request) => Promise<Response>;
|
|
|
187
207
|
*/
|
|
188
208
|
export declare function isResolvedPathInsideRoot(path: string, root: string): boolean;
|
|
189
209
|
|
|
190
|
-
export declare function loadCredentials(
|
|
210
|
+
export declare function loadCredentials(): VmCredentials;
|
|
211
|
+
|
|
212
|
+
export declare interface ManagedExecDiagnostic {
|
|
213
|
+
event: 'managed_exec.backend_retirement';
|
|
214
|
+
trigger: 'cancellation' | 'timeout';
|
|
215
|
+
outcome: 'started' | 'succeeded' | 'failed';
|
|
216
|
+
message: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export declare interface ManagedExecOptions {
|
|
220
|
+
cwd?: string;
|
|
221
|
+
env?: Record<string, string>;
|
|
222
|
+
signal?: AbortSignal;
|
|
223
|
+
timeoutMs?: number;
|
|
224
|
+
onData?: (data: Buffer, stream: 'stdout' | 'stderr') => void;
|
|
225
|
+
onDiagnostic?: (diagnostic: ManagedExecDiagnostic) => void;
|
|
226
|
+
onStarted?: () => void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export declare interface ManagedExecResult {
|
|
230
|
+
exitCode: number;
|
|
231
|
+
timedOut: boolean;
|
|
232
|
+
cancelled: boolean;
|
|
233
|
+
termination: ManagedExecTermination;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export declare type ManagedExecTermination = {
|
|
237
|
+
status: 'not-required';
|
|
238
|
+
} | {
|
|
239
|
+
status: 'not-started';
|
|
240
|
+
} | {
|
|
241
|
+
status: 'backend-retired';
|
|
242
|
+
mode: 'vm-close';
|
|
243
|
+
} | {
|
|
244
|
+
status: 'recovery-required';
|
|
245
|
+
reason: 'backend-retirement-failed';
|
|
246
|
+
};
|
|
191
247
|
|
|
192
248
|
export declare interface ManagedVm {
|
|
193
249
|
vm: VM;
|
|
@@ -211,11 +267,10 @@ export declare function prepareBrokeredHttpSecrets(options: {
|
|
|
211
267
|
occupiedGuestEnvNames?: readonly string[];
|
|
212
268
|
}): Record<string, SecretDefinition>;
|
|
213
269
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
guestPath: string;
|
|
270
|
+
declare interface RequestOrigin {
|
|
271
|
+
hostname: string;
|
|
272
|
+
protocol: string;
|
|
273
|
+
port: number;
|
|
219
274
|
}
|
|
220
275
|
|
|
221
276
|
export declare function resolveVfsShadowConfig(config: SandboxConfig | undefined): {
|
|
@@ -259,44 +314,6 @@ export declare interface ResumeCommandWhen {
|
|
|
259
314
|
*/
|
|
260
315
|
export declare function resumeVm(config: VmConfig): Promise<ManagedVm>;
|
|
261
316
|
|
|
262
|
-
/**
|
|
263
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
264
|
-
* equivalents before injecting it into the guest.
|
|
265
|
-
*
|
|
266
|
-
* Two rewrites:
|
|
267
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
268
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
269
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
270
|
-
*
|
|
271
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
272
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
273
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
274
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
275
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
276
|
-
* `signingKey`.
|
|
277
|
-
*
|
|
278
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
279
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
280
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
281
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
282
|
-
*/
|
|
283
|
-
export declare function rewriteGitconfigPaths(gitconfig: string, vmSshDir: string, vmAgentDir: string): string;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
287
|
-
*
|
|
288
|
-
* Fields rewritten:
|
|
289
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
290
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
291
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
292
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
293
|
-
*
|
|
294
|
-
* All other fields are passed through unchanged.
|
|
295
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
296
|
-
* moltnet.json into the guest.
|
|
297
|
-
*/
|
|
298
|
-
export declare function rewriteMoltnetJsonPaths(moltnetJson: string, vmAgentDir: string, vmSshDir: string, githubAppPemFilename: string | null): string;
|
|
299
|
-
|
|
300
317
|
export declare interface SandboxConfig {
|
|
301
318
|
/**
|
|
302
319
|
* Operator-owned snapshot build settings. Runtime profiles must never
|
|
@@ -386,12 +403,6 @@ export declare interface VmConfig {
|
|
|
386
403
|
checkpointPath: string;
|
|
387
404
|
/** MoltNet agent name (used to resolve credentials). */
|
|
388
405
|
agentName: string;
|
|
389
|
-
/**
|
|
390
|
-
* Trust boundary for guest credentials. `guest-config` injects the complete
|
|
391
|
-
* legacy agent directory. `host-authenticated` never reads or injects it and
|
|
392
|
-
* relies exclusively on the supplied host-side Agent for MoltNet operations.
|
|
393
|
-
*/
|
|
394
|
-
guestCredentialMode?: GuestCredentialMode;
|
|
395
406
|
/**
|
|
396
407
|
* Host root that owns `.moltnet/<agentName>/`.
|
|
397
408
|
*
|
|
@@ -438,43 +449,21 @@ export declare interface VmConfig {
|
|
|
438
449
|
onDiagnostic?: (diagnostic: VmDiagnostic) => void;
|
|
439
450
|
/** Abort resume/setup work, closing any live VM owned by resumeVm. */
|
|
440
451
|
signal?: AbortSignal;
|
|
441
|
-
/**
|
|
442
|
-
* Coding-agent provider authentication to place in the guest (for example
|
|
443
|
-
* Pi's `auth.json`). The sandbox knows nothing about the provider: the
|
|
444
|
-
* runtime above it supplies the loader and the guest path. When omitted,
|
|
445
|
-
* nothing is written and the guest relies on forwarded env providers.
|
|
446
|
-
*/
|
|
447
|
-
providerAuth?: ProviderAuthSource;
|
|
448
452
|
}
|
|
449
453
|
|
|
450
454
|
export declare interface VmCredentials {
|
|
451
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
452
|
-
moltnetJson: string;
|
|
453
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
454
|
-
agentEnvRaw: string;
|
|
455
455
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* and the forwarded host environment instead. CI uses this path.
|
|
456
|
+
* Guest env carried across the boundary. Always empty: the guest receives no
|
|
457
|
+
* MoltNet credential material. Retained for API stability and because
|
|
458
|
+
* consumers read (empty) provider/diary/team hints from it.
|
|
460
459
|
*/
|
|
461
|
-
providerAuthJson: string | null;
|
|
462
460
|
agentEnv: Record<string, string | undefined>;
|
|
463
|
-
gitconfig: string | null;
|
|
464
|
-
sshPrivateKey: string | null;
|
|
465
|
-
sshPublicKey: string | null;
|
|
466
|
-
allowedSigners: string | null;
|
|
467
|
-
/** Raw PEM content of the GitHub App private key, or null if not configured. */
|
|
468
|
-
githubAppPem: string | null;
|
|
469
|
-
/** VM-local filename for the GitHub App PEM (basename of host path), or null. */
|
|
470
|
-
githubAppPemFilename: string | null;
|
|
471
461
|
}
|
|
472
462
|
|
|
473
463
|
export declare interface VmDiagnostic {
|
|
474
|
-
event: 'vm.credentials.mode' | 'vm.
|
|
464
|
+
event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.origin_denied';
|
|
475
465
|
level: 'info' | 'warning';
|
|
476
466
|
message: string;
|
|
477
|
-
credentialMode: GuestCredentialMode;
|
|
478
467
|
/** Present only for the value-free broker summary event. */
|
|
479
468
|
brokeredSecretCount?: number;
|
|
480
469
|
/** Present only for the host-origins summary event. */
|
|
@@ -482,6 +471,13 @@ export declare interface VmDiagnostic {
|
|
|
482
471
|
/** Present only for the guest-projection summary event. */
|
|
483
472
|
projectedFileCount?: number;
|
|
484
473
|
projectedServiceCount?: number;
|
|
474
|
+
/** Present only for an exact-origin denial event. */
|
|
475
|
+
origin?: {
|
|
476
|
+
hostname: string;
|
|
477
|
+
protocol: string;
|
|
478
|
+
port: number;
|
|
479
|
+
phase: 'request' | 'ip';
|
|
480
|
+
};
|
|
485
481
|
}
|
|
486
482
|
|
|
487
483
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path, { isAbsolute, relative } from "node:path";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync,
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
5
5
|
import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, isWriteFlag, loadGuestAssets } from "@earendil-works/gondolin";
|
|
6
|
-
import {
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
7
|
//#region src/abort-utils.ts
|
|
8
8
|
function throwIfAborted(signal, label) {
|
|
9
9
|
if (!signal?.aborted) return;
|
|
@@ -61,6 +61,138 @@ async function delay(ms, signal, label) {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
//#endregion
|
|
64
|
+
//#region src/managed-exec.ts
|
|
65
|
+
function chunkBuffer(data) {
|
|
66
|
+
if (Buffer.isBuffer(data)) return data;
|
|
67
|
+
return typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
68
|
+
}
|
|
69
|
+
async function retireBackend(vm, trigger, onDiagnostic) {
|
|
70
|
+
onDiagnostic?.({
|
|
71
|
+
event: "managed_exec.backend_retirement",
|
|
72
|
+
trigger,
|
|
73
|
+
outcome: "started",
|
|
74
|
+
message: "retiring Gondolin VM after interrupted guest execution"
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
await vm.close();
|
|
78
|
+
onDiagnostic?.({
|
|
79
|
+
event: "managed_exec.backend_retirement",
|
|
80
|
+
trigger,
|
|
81
|
+
outcome: "succeeded",
|
|
82
|
+
message: "Gondolin VM retirement completed"
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
status: "backend-retired",
|
|
86
|
+
mode: "vm-close"
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
onDiagnostic?.({
|
|
90
|
+
event: "managed_exec.backend_retirement",
|
|
91
|
+
trigger,
|
|
92
|
+
outcome: "failed",
|
|
93
|
+
message: error instanceof Error ? `Gondolin VM retirement failed: ${error.message}` : "Gondolin VM retirement failed"
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
status: "recovery-required",
|
|
97
|
+
reason: "backend-retirement-failed"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
103
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
104
|
+
* host API before returning, because no guest process/session boundary can
|
|
105
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
106
|
+
*/
|
|
107
|
+
async function execManagedCommand(vm, command, options = {}) {
|
|
108
|
+
if (options.signal?.aborted) return {
|
|
109
|
+
exitCode: 130,
|
|
110
|
+
timedOut: false,
|
|
111
|
+
cancelled: true,
|
|
112
|
+
termination: { status: "not-started" }
|
|
113
|
+
};
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
let cancelled = false;
|
|
117
|
+
let executionSettled = false;
|
|
118
|
+
let acceptOutput = true;
|
|
119
|
+
let resolveInterrupted;
|
|
120
|
+
let retirementPromise;
|
|
121
|
+
const interrupted = new Promise((resolve) => {
|
|
122
|
+
resolveInterrupted = resolve;
|
|
123
|
+
});
|
|
124
|
+
const interrupt = (trigger) => {
|
|
125
|
+
if (executionSettled || retirementPromise) return;
|
|
126
|
+
acceptOutput = false;
|
|
127
|
+
controller.abort(/* @__PURE__ */ new Error(`guest command interrupted by ${trigger}`));
|
|
128
|
+
retirementPromise = retireBackend(vm, trigger, options.onDiagnostic);
|
|
129
|
+
resolveInterrupted?.();
|
|
130
|
+
};
|
|
131
|
+
const onAbort = () => {
|
|
132
|
+
cancelled = true;
|
|
133
|
+
interrupt("cancellation");
|
|
134
|
+
};
|
|
135
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
const timer = options.timeoutMs !== void 0 && options.timeoutMs > 0 ? setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
interrupt("timeout");
|
|
139
|
+
}, options.timeoutMs) : void 0;
|
|
140
|
+
timer?.unref();
|
|
141
|
+
try {
|
|
142
|
+
const process = vm.exec([
|
|
143
|
+
"/bin/sh",
|
|
144
|
+
"-lc",
|
|
145
|
+
command
|
|
146
|
+
], {
|
|
147
|
+
cwd: options.cwd,
|
|
148
|
+
env: options.env,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "pipe"
|
|
152
|
+
});
|
|
153
|
+
options.onStarted?.();
|
|
154
|
+
const execution = (async () => {
|
|
155
|
+
for await (const chunk of process.output()) {
|
|
156
|
+
if (!acceptOutput) continue;
|
|
157
|
+
options.onData?.(chunkBuffer(chunk.data), chunk.stream);
|
|
158
|
+
}
|
|
159
|
+
return process;
|
|
160
|
+
})();
|
|
161
|
+
const observedExecution = execution.then((result) => {
|
|
162
|
+
executionSettled = true;
|
|
163
|
+
return {
|
|
164
|
+
kind: "result",
|
|
165
|
+
result
|
|
166
|
+
};
|
|
167
|
+
}, (error) => {
|
|
168
|
+
executionSettled = true;
|
|
169
|
+
return {
|
|
170
|
+
kind: "error",
|
|
171
|
+
error
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
const first = await Promise.race([observedExecution, interrupted.then(() => ({ kind: "interrupted" }))]);
|
|
175
|
+
if (first.kind === "result" && !timedOut && !cancelled) return {
|
|
176
|
+
exitCode: first.result.exitCode,
|
|
177
|
+
timedOut: false,
|
|
178
|
+
cancelled: false,
|
|
179
|
+
termination: { status: "not-required" }
|
|
180
|
+
};
|
|
181
|
+
if (first.kind === "error" && !timedOut && !cancelled) throw first.error;
|
|
182
|
+
execution.catch(() => void 0);
|
|
183
|
+
const termination = await (retirementPromise ??= retireBackend(vm, timedOut ? "timeout" : "cancellation", options.onDiagnostic));
|
|
184
|
+
return {
|
|
185
|
+
exitCode: timedOut ? 124 : 130,
|
|
186
|
+
timedOut,
|
|
187
|
+
cancelled: cancelled && !timedOut,
|
|
188
|
+
termination
|
|
189
|
+
};
|
|
190
|
+
} finally {
|
|
191
|
+
if (timer) clearTimeout(timer);
|
|
192
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
64
196
|
//#region src/path-containment.ts
|
|
65
197
|
/**
|
|
66
198
|
* Check containment for already-resolved lexical or real paths.
|
|
@@ -432,59 +564,8 @@ function resolveVmAgentDir(config) {
|
|
|
432
564
|
const rootDir = config.agentRootDir ?? findMainWorktree();
|
|
433
565
|
return path.join(rootDir, ".moltnet", config.agentName);
|
|
434
566
|
}
|
|
435
|
-
function loadCredentials(
|
|
436
|
-
|
|
437
|
-
const agentEnvPath = path.join(agentDir, "env");
|
|
438
|
-
const providerAuthJson = providerAuth?.load() ?? null;
|
|
439
|
-
if (mode === "host-authenticated") return {
|
|
440
|
-
moltnetJson: "",
|
|
441
|
-
agentEnvRaw: "",
|
|
442
|
-
providerAuthJson,
|
|
443
|
-
agentEnv: {},
|
|
444
|
-
gitconfig: null,
|
|
445
|
-
sshPrivateKey: null,
|
|
446
|
-
sshPublicKey: null,
|
|
447
|
-
allowedSigners: null,
|
|
448
|
-
githubAppPem: null,
|
|
449
|
-
githubAppPemFilename: null
|
|
450
|
-
};
|
|
451
|
-
const hasMoltnetJson = existsSync(moltnetPath);
|
|
452
|
-
const hasAgentEnv = existsSync(agentEnvPath);
|
|
453
|
-
if (!hasMoltnetJson || !hasAgentEnv) throw new Error(`Guest credential mode requires both ${moltnetPath} and ${agentEnvPath}`);
|
|
454
|
-
const moltnetJson = readFileSync(moltnetPath, "utf8");
|
|
455
|
-
const agentEnvRaw = readFileSync(agentEnvPath, "utf8");
|
|
456
|
-
if (moltnetJson.trim() === "") throw new Error(`Agent configuration is empty: ${moltnetPath}`);
|
|
457
|
-
const gitconfigPath = path.join(agentDir, "gitconfig");
|
|
458
|
-
const gitconfig = existsSync(gitconfigPath) ? readFileSync(gitconfigPath, "utf8") : null;
|
|
459
|
-
const sshDir = path.join(agentDir, "ssh");
|
|
460
|
-
const sshPrivateKey = existsSync(path.join(sshDir, "id_ed25519")) ? readFileSync(path.join(sshDir, "id_ed25519"), "utf8") : null;
|
|
461
|
-
const sshPublicKey = existsSync(path.join(sshDir, "id_ed25519.pub")) ? readFileSync(path.join(sshDir, "id_ed25519.pub"), "utf8") : null;
|
|
462
|
-
const allowedSigners = existsSync(path.join(sshDir, "allowed_signers")) ? readFileSync(path.join(sshDir, "allowed_signers"), "utf8") : null;
|
|
463
|
-
let githubAppPem = null;
|
|
464
|
-
let githubAppPemFilename = null;
|
|
465
|
-
const pemPath = (moltnetJson ? JSON.parse(moltnetJson) : null)?.github?.private_key_path;
|
|
466
|
-
if (pemPath) if (!existsSync(pemPath)) onDiagnostic?.({
|
|
467
|
-
event: "vm.credentials.github_key_missing",
|
|
468
|
-
level: "warning",
|
|
469
|
-
credentialMode: mode,
|
|
470
|
-
message: `github.private_key_path not found at ${pemPath}; moltnet github token will fail inside the guest`
|
|
471
|
-
});
|
|
472
|
-
else {
|
|
473
|
-
githubAppPem = readFileSync(pemPath, "utf8");
|
|
474
|
-
githubAppPemFilename = path.basename(pemPath);
|
|
475
|
-
}
|
|
476
|
-
return {
|
|
477
|
-
moltnetJson,
|
|
478
|
-
agentEnvRaw,
|
|
479
|
-
providerAuthJson,
|
|
480
|
-
agentEnv: parseEnv(agentEnvRaw),
|
|
481
|
-
gitconfig,
|
|
482
|
-
sshPrivateKey,
|
|
483
|
-
sshPublicKey,
|
|
484
|
-
allowedSigners,
|
|
485
|
-
githubAppPem,
|
|
486
|
-
githubAppPemFilename
|
|
487
|
-
};
|
|
567
|
+
function loadCredentials() {
|
|
568
|
+
return { agentEnv: {} };
|
|
488
569
|
}
|
|
489
570
|
/**
|
|
490
571
|
* Apply agent env vars to the host process, mirroring `moltnet start`.
|
|
@@ -580,9 +661,13 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
|
|
|
580
661
|
"prototype"
|
|
581
662
|
]);
|
|
582
663
|
function normalizeHostPattern(pattern) {
|
|
583
|
-
|
|
664
|
+
let normalized = pattern.trim().toLowerCase();
|
|
665
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
|
|
666
|
+
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
|
|
667
|
+
return normalized;
|
|
584
668
|
}
|
|
585
669
|
function isValidHostPattern(pattern) {
|
|
670
|
+
if (isIP(pattern) !== 0) return true;
|
|
586
671
|
return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
|
|
587
672
|
}
|
|
588
673
|
/**
|
|
@@ -617,8 +702,8 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
617
702
|
});
|
|
618
703
|
}
|
|
619
704
|
function hostMatchesPattern(hostname, pattern) {
|
|
620
|
-
const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
621
|
-
return new RegExp(`^${expression}$`, "i").test(hostname);
|
|
705
|
+
const expression = normalizeHostPattern(pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
706
|
+
return new RegExp(`^${expression}$`, "i").test(normalizeHostPattern(hostname));
|
|
622
707
|
}
|
|
623
708
|
/**
|
|
624
709
|
* Conservative subset check for Gondolin hostname globs. Exact secret hosts
|
|
@@ -690,6 +775,19 @@ function headersContainSecretValue(headers, value) {
|
|
|
690
775
|
}
|
|
691
776
|
return false;
|
|
692
777
|
}
|
|
778
|
+
function parseRequestOrigin(url) {
|
|
779
|
+
try {
|
|
780
|
+
const parsed = new URL(url);
|
|
781
|
+
const protocol = parsed.protocol.slice(0, -1);
|
|
782
|
+
return {
|
|
783
|
+
hostname: normalizeHostPattern(parsed.hostname),
|
|
784
|
+
protocol,
|
|
785
|
+
port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
|
|
786
|
+
};
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
693
791
|
function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
694
792
|
const entries = /* @__PURE__ */ new Map();
|
|
695
793
|
for (const binding of bindings) {
|
|
@@ -706,17 +804,11 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
706
804
|
}
|
|
707
805
|
return {
|
|
708
806
|
isRequestAllowed(request) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
url = new URL(request.url);
|
|
712
|
-
} catch {
|
|
713
|
-
return false;
|
|
714
|
-
}
|
|
715
|
-
const protocol = url.protocol.slice(0, -1);
|
|
716
|
-
const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
|
|
807
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
808
|
+
if (!requestOrigin) return false;
|
|
717
809
|
for (const entry of entries.values()) {
|
|
718
810
|
if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
|
|
719
|
-
if (protocol !== entry.protocol || !entry.ports.includes(port) || !entry.hosts.some((host) => hostMatchesPattern(
|
|
811
|
+
if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
|
|
720
812
|
}
|
|
721
813
|
return true;
|
|
722
814
|
},
|
|
@@ -733,18 +825,51 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
733
825
|
}
|
|
734
826
|
};
|
|
735
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* Refine Gondolin's hostname allowlist for hosts carrying brokered
|
|
830
|
+
* credentials. The built-in allowlist is hostname-granular; these callbacks
|
|
831
|
+
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
832
|
+
*/
|
|
833
|
+
function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
|
|
834
|
+
const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
|
|
835
|
+
const isAllowed = (input) => {
|
|
836
|
+
const hostname = normalizeHostPattern(input.hostname);
|
|
837
|
+
const matching = origins.filter((origin) => origin.hosts.some((host) => hostMatchesPattern(hostname, host)));
|
|
838
|
+
if (matching.length === 0) return true;
|
|
839
|
+
const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
|
|
840
|
+
if (!allowed) options.onDenied?.({
|
|
841
|
+
hostname,
|
|
842
|
+
protocol: input.protocol,
|
|
843
|
+
port: input.port,
|
|
844
|
+
phase: input.phase
|
|
845
|
+
});
|
|
846
|
+
return allowed;
|
|
847
|
+
};
|
|
848
|
+
return {
|
|
849
|
+
isRequestAllowed(request) {
|
|
850
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
851
|
+
return requestOrigin ? isAllowed({
|
|
852
|
+
...requestOrigin,
|
|
853
|
+
phase: "request"
|
|
854
|
+
}) : false;
|
|
855
|
+
},
|
|
856
|
+
isIpAllowed(info) {
|
|
857
|
+
return isAllowed({
|
|
858
|
+
...info,
|
|
859
|
+
phase: "ip"
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
736
864
|
function assertGuestEnvironmentBoundary(options) {
|
|
737
|
-
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) ||
|
|
865
|
+
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
|
|
738
866
|
const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
|
|
739
867
|
const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
|
|
740
868
|
if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
|
|
741
869
|
}
|
|
742
|
-
/** @deprecated
|
|
870
|
+
/** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
|
|
743
871
|
function assertHostAuthenticatedGuestEnvironment(options) {
|
|
744
|
-
assertGuestEnvironmentBoundary(
|
|
745
|
-
guestCredentialMode: "host-authenticated",
|
|
746
|
-
...options
|
|
747
|
-
});
|
|
872
|
+
assertGuestEnvironmentBoundary(options);
|
|
748
873
|
}
|
|
749
874
|
/**
|
|
750
875
|
* Return whether two Gondolin hostname globs can match at least one common
|
|
@@ -821,22 +946,17 @@ async function resumeVm(config) {
|
|
|
821
946
|
throwIfAborted(config.signal, "VM resume");
|
|
822
947
|
const agentDir = resolveVmAgentDir(config);
|
|
823
948
|
const guestWorkspace = path.resolve(config.mountPath);
|
|
824
|
-
const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
|
|
825
|
-
if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
|
|
826
949
|
assertGuestEnvironmentBoundary({
|
|
827
|
-
guestCredentialMode,
|
|
828
950
|
forwardEnv: config.forwardEnv,
|
|
829
951
|
sandboxEnv: config.sandboxConfig?.env
|
|
830
952
|
});
|
|
831
953
|
config.onDiagnostic?.({
|
|
832
954
|
event: "vm.credentials.mode",
|
|
833
955
|
level: "info",
|
|
834
|
-
|
|
835
|
-
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"
|
|
956
|
+
message: "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest"
|
|
836
957
|
});
|
|
837
|
-
const creds = loadCredentials(
|
|
838
|
-
const
|
|
839
|
-
const apiHost = new URL(configuredApiUrl ?? process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
958
|
+
const creds = loadCredentials();
|
|
959
|
+
const apiHost = new URL(process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
840
960
|
const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
|
|
841
961
|
const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
|
|
842
962
|
const protectedExternalHosts = [...new Set([
|
|
@@ -867,12 +987,21 @@ async function resumeVm(config) {
|
|
|
867
987
|
]
|
|
868
988
|
});
|
|
869
989
|
const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
|
|
990
|
+
const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDenied(origin) {
|
|
991
|
+
config.onDiagnostic?.({
|
|
992
|
+
event: "vm.network.origin_denied",
|
|
993
|
+
level: "warning",
|
|
994
|
+
message: "denied request outside a brokered credential origin",
|
|
995
|
+
origin
|
|
996
|
+
});
|
|
997
|
+
} });
|
|
870
998
|
const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
|
|
871
999
|
allowedHosts,
|
|
872
1000
|
allowedInternalHosts,
|
|
873
1001
|
...Object.keys(brokeredSecrets).length > 0 && {
|
|
874
1002
|
secrets: brokeredSecrets,
|
|
875
|
-
isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
|
|
1003
|
+
isRequestAllowed: (request) => brokeredNetworkOriginPolicy.isRequestAllowed(request) && brokeredSecretOriginPolicy.isRequestAllowed(request),
|
|
1004
|
+
isIpAllowed: brokeredNetworkOriginPolicy.isIpAllowed
|
|
876
1005
|
},
|
|
877
1006
|
...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
|
|
878
1007
|
});
|
|
@@ -890,26 +1019,15 @@ async function resumeVm(config) {
|
|
|
890
1019
|
if (brokeredSecretCount > 0) config.onDiagnostic?.({
|
|
891
1020
|
event: "vm.http_secrets.bound",
|
|
892
1021
|
level: "info",
|
|
893
|
-
credentialMode: guestCredentialMode,
|
|
894
1022
|
brokeredSecretCount,
|
|
895
1023
|
message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
|
|
896
1024
|
});
|
|
897
1025
|
if (hostOriginHosts.length > 0) config.onDiagnostic?.({
|
|
898
1026
|
event: "vm.host_origins.bound",
|
|
899
1027
|
level: "info",
|
|
900
|
-
credentialMode: guestCredentialMode,
|
|
901
1028
|
hostOriginCount: hostOriginHosts.length,
|
|
902
1029
|
message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
|
|
903
1030
|
});
|
|
904
|
-
const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
|
|
905
|
-
const vmAgentEnv = {};
|
|
906
|
-
for (const [k, v] of Object.entries(creds.agentEnv)) {
|
|
907
|
-
if (v === void 0 || v === "") continue;
|
|
908
|
-
if (k === "GIT_CONFIG_GLOBAL") vmAgentEnv[k] = `${vmAgentDir}/gitconfig`;
|
|
909
|
-
else if (k.endsWith("_PRIVATE_KEY_PATH")) vmAgentEnv[k] = `${vmAgentDir}/${path.basename(v)}`;
|
|
910
|
-
else vmAgentEnv[k] = v;
|
|
911
|
-
}
|
|
912
|
-
if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
|
|
913
1031
|
const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
|
|
914
1032
|
let workspaceProvider = new RealFSProvider(config.mountPath);
|
|
915
1033
|
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
@@ -925,7 +1043,7 @@ async function resumeVm(config) {
|
|
|
925
1043
|
writeMode: vfsConfig.mode
|
|
926
1044
|
});
|
|
927
1045
|
}
|
|
928
|
-
|
|
1046
|
+
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
929
1047
|
shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
|
|
930
1048
|
denySymlinkBypass: true,
|
|
931
1049
|
writeMode: "deny"
|
|
@@ -938,7 +1056,6 @@ async function resumeVm(config) {
|
|
|
938
1056
|
}
|
|
939
1057
|
const envOverrides = config.sandboxConfig?.env ?? {};
|
|
940
1058
|
const vmEnv = {
|
|
941
|
-
...vmAgentEnv,
|
|
942
1059
|
...forwardedEnv,
|
|
943
1060
|
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
|
|
944
1061
|
HOME: "/home/agent",
|
|
@@ -1023,55 +1140,10 @@ async function resumeVm(config) {
|
|
|
1023
1140
|
}
|
|
1024
1141
|
if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
|
|
1025
1142
|
}
|
|
1026
|
-
const vmSshDir = `${vmAgentDir}/ssh`;
|
|
1027
|
-
const hasAgentFiles = guestCredentialMode === "guest-config";
|
|
1028
|
-
const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
|
|
1029
1143
|
const projectedFiles = config.guestProjection?.files ?? [];
|
|
1030
1144
|
const projectedDirs = [...new Set(projectedFiles.map((file) => path.posix.dirname(file.path)))];
|
|
1031
|
-
const guestDirs = [
|
|
1032
|
-
...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [],
|
|
1033
|
-
...providerAuthDir ? [providerAuthDir] : [],
|
|
1034
|
-
...projectedDirs
|
|
1035
|
-
];
|
|
1145
|
+
const guestDirs = [...projectedDirs];
|
|
1036
1146
|
if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
|
|
1037
|
-
if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
|
|
1038
|
-
mode: 384,
|
|
1039
|
-
signal: config.signal
|
|
1040
|
-
});
|
|
1041
|
-
if (hasAgentFiles) {
|
|
1042
|
-
const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
|
|
1043
|
-
await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
|
|
1044
|
-
mode: 384,
|
|
1045
|
-
signal: config.signal
|
|
1046
|
-
});
|
|
1047
|
-
await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, {
|
|
1048
|
-
mode: 384,
|
|
1049
|
-
signal: config.signal
|
|
1050
|
-
});
|
|
1051
|
-
if (creds.gitconfig) {
|
|
1052
|
-
const vmGitconfig = rewriteGitconfigPaths(creds.gitconfig, vmSshDir, vmAgentDir);
|
|
1053
|
-
await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, {
|
|
1054
|
-
mode: 420,
|
|
1055
|
-
signal: config.signal
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
|
|
1059
|
-
mode: 384,
|
|
1060
|
-
signal: config.signal
|
|
1061
|
-
});
|
|
1062
|
-
if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
|
|
1063
|
-
mode: 420,
|
|
1064
|
-
signal: config.signal
|
|
1065
|
-
});
|
|
1066
|
-
if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
|
|
1067
|
-
mode: 420,
|
|
1068
|
-
signal: config.signal
|
|
1069
|
-
});
|
|
1070
|
-
if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
|
|
1071
|
-
mode: 384,
|
|
1072
|
-
signal: config.signal
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
1147
|
for (const file of projectedFiles) {
|
|
1076
1148
|
await vm.fs.writeFile(file.path, file.content, {
|
|
1077
1149
|
mode: file.mode ?? 420,
|
|
@@ -1079,11 +1151,7 @@ async function resumeVm(config) {
|
|
|
1079
1151
|
});
|
|
1080
1152
|
if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
|
|
1081
1153
|
}
|
|
1082
|
-
await vmRun(vm, "chown guest directories", `set -e; for d in ${[
|
|
1083
|
-
"/home/agent/.pi",
|
|
1084
|
-
...hasAgentFiles ? ["/home/agent/.moltnet"] : [],
|
|
1085
|
-
...projectedDirs.filter((dir) => dir.startsWith("/home/agent/"))
|
|
1086
|
-
].map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
|
|
1154
|
+
await vmRun(vm, "chown guest directories", `set -e; for d in ${projectedDirs.filter((dir) => dir.startsWith("/home/agent/")).map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
|
|
1087
1155
|
const projectedServices = config.guestProjection?.services ?? [];
|
|
1088
1156
|
if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
|
|
1089
1157
|
for (const service of projectedServices) {
|
|
@@ -1135,7 +1203,6 @@ async function resumeVm(config) {
|
|
|
1135
1203
|
config.onDiagnostic?.({
|
|
1136
1204
|
event: "vm.guest_service.not_ready",
|
|
1137
1205
|
level: "warning",
|
|
1138
|
-
credentialMode: guestCredentialMode,
|
|
1139
1206
|
message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
|
|
1140
1207
|
});
|
|
1141
1208
|
}
|
|
@@ -1144,7 +1211,6 @@ async function resumeVm(config) {
|
|
|
1144
1211
|
if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
|
|
1145
1212
|
event: "vm.guest_projection.applied",
|
|
1146
1213
|
level: "info",
|
|
1147
|
-
credentialMode: guestCredentialMode,
|
|
1148
1214
|
projectedFileCount: projectedFiles.length,
|
|
1149
1215
|
projectedServiceCount: projectedServices.length,
|
|
1150
1216
|
message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
|
|
@@ -1169,66 +1235,5 @@ async function resumeVm(config) {
|
|
|
1169
1235
|
throw err;
|
|
1170
1236
|
}
|
|
1171
1237
|
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
1174
|
-
* equivalents before injecting it into the guest.
|
|
1175
|
-
*
|
|
1176
|
-
* Two rewrites:
|
|
1177
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
1178
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
1179
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
1180
|
-
*
|
|
1181
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
1182
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
1183
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
1184
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
1185
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
1186
|
-
* `signingKey`.
|
|
1187
|
-
*
|
|
1188
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
1189
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
1190
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
1191
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
1192
|
-
*/
|
|
1193
|
-
function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
|
|
1194
|
-
return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
|
|
1195
|
-
}
|
|
1196
|
-
/**
|
|
1197
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
1198
|
-
*
|
|
1199
|
-
* Fields rewritten:
|
|
1200
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
1201
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
1202
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
1203
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
1204
|
-
*
|
|
1205
|
-
* All other fields are passed through unchanged.
|
|
1206
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
1207
|
-
* moltnet.json into the guest.
|
|
1208
|
-
*/
|
|
1209
|
-
function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
|
|
1210
|
-
const config = JSON.parse(moltnetJson);
|
|
1211
|
-
if (config.ssh && typeof config.ssh === "object") {
|
|
1212
|
-
const ssh = config.ssh;
|
|
1213
|
-
const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
|
|
1214
|
-
const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
|
|
1215
|
-
config.ssh = {
|
|
1216
|
-
...ssh,
|
|
1217
|
-
...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
|
|
1218
|
-
...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
if (config.git && typeof config.git === "object") {
|
|
1222
|
-
const git = { ...config.git };
|
|
1223
|
-
git.config_path = `${vmAgentDir}/gitconfig`;
|
|
1224
|
-
config.git = git;
|
|
1225
|
-
}
|
|
1226
|
-
if (githubAppPemFilename && config.github && typeof config.github === "object") {
|
|
1227
|
-
const github = { ...config.github };
|
|
1228
|
-
github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
|
|
1229
|
-
config.github = github;
|
|
1230
|
-
}
|
|
1231
|
-
return JSON.stringify(config);
|
|
1232
|
-
}
|
|
1233
1238
|
//#endregion
|
|
1234
|
-
export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm,
|
|
1239
|
+
export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, createBrokeredHttpNetworkOriginPolicy, delay, ensureSnapshot, execManagedCommand, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@themoltnet/sandbox-gondolin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Gondolin microVM sandbox lifecycle for MoltNet runtimes: checkpoint resume, egress policy, VFS shadowing, guest credential boundary, host-brokered secrets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moltnet",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@earendil-works/gondolin": "^0.
|
|
50
|
+
"@earendil-works/gondolin": "^0.12.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.19.0",
|