@themoltnet/sandbox-gondolin 0.2.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 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: `guest-config` injects the complete
18
- `.moltnet/<agent>` tree; `host-authenticated` withholds it and refuses
19
- `MOLTNET_*` names in runtime-controlled guest environment
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.** A runtime supplies `VmConfig.providerAuth`
31
- (`{ load(): string | null; guestPath }`); the sandbox only carries the blob
32
- across the boundary. Pi's `~/.pi/agent/auth.json` handling lives in
33
- `@themoltnet/pi-runtime` (`piProviderAuth`, and its `resumeVm` wrapper).
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 Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
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).
@@ -109,6 +131,9 @@ export declare function findMainWorktree(startPath?: string): string;
109
131
  /** Commands guaranteed by the base Gondolin snapshot. */
110
132
  export declare const GONDOLIN_BASE_EXECUTABLES: readonly string[];
111
133
 
134
+ /** Guest directory holding one PID file per projected service. */
135
+ export declare const GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
136
+
112
137
  /**
113
138
  * Memory-backed VFS mount used by the daemon to inject task context
114
139
  * (#943 slice 1.5). This is a separate top-level mount because Gondolin
@@ -134,13 +159,44 @@ export declare const GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
134
159
  /** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
135
160
  export declare const GUEST_TASK_SKILLS_MOUNT = "/moltnet-task-context";
136
161
 
137
- export declare type GuestCredentialMode = 'guest-config' | 'host-authenticated';
138
-
139
162
  export declare class GuestEnvironmentBoundaryError extends Error {
140
163
  readonly refusedNames: readonly string[];
141
164
  constructor(refusedNames: readonly string[]);
142
165
  }
143
166
 
167
+ /** Declarative, trusted-host guest projection (env, files, services). */
168
+ export declare interface GuestProjectionInput {
169
+ env?: Record<string, string>;
170
+ files?: {
171
+ path: string;
172
+ content: string | Uint8Array;
173
+ mode?: number;
174
+ }[];
175
+ services?: {
176
+ id: string;
177
+ command: readonly string[];
178
+ env?: Record<string, string>;
179
+ /** Guest path to wait for before the session starts. */
180
+ readiness?: {
181
+ path: string;
182
+ timeoutMs?: number;
183
+ required?: boolean;
184
+ };
185
+ }[];
186
+ }
187
+
188
+ /** Lifecycle handle for projected guest services. */
189
+ export declare interface GuestServices {
190
+ stop(): Promise<void>;
191
+ }
192
+
193
+ /**
194
+ * Host origins: hostnames the guest can reach whose responses are produced by
195
+ * host code inside the proxy (`onRequest` short-circuit). Nothing is listened
196
+ * on, nothing is forwarded; the sandbox knows only the origin → handler map.
197
+ */
198
+ declare type HostOriginHandler = (request: Request) => Promise<Response>;
199
+
144
200
  /**
145
201
  * Check containment for already-resolved lexical or real paths.
146
202
  *
@@ -151,13 +207,51 @@ export declare class GuestEnvironmentBoundaryError extends Error {
151
207
  */
152
208
  export declare function isResolvedPathInsideRoot(path: string, root: string): boolean;
153
209
 
154
- export declare function loadCredentials(agentDir: string, mode?: GuestCredentialMode, onDiagnostic?: (diagnostic: VmDiagnostic) => void, providerAuth?: ProviderAuthSource): VmCredentials;
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
+ };
155
247
 
156
248
  export declare interface ManagedVm {
157
249
  vm: VM;
158
250
  credentials: VmCredentials;
159
251
  /** Host-only rotation and revocation handle. It cannot widen destinations. */
160
252
  secretManager: BrokeredHttpSecretManager;
253
+ /** Projected guest services; `stop()` before closing the VM. Always present. */
254
+ services: GuestServices;
161
255
  mountPath: string;
162
256
  guestWorkspace: string;
163
257
  agentDir: string;
@@ -173,11 +267,10 @@ export declare function prepareBrokeredHttpSecrets(options: {
173
267
  occupiedGuestEnvNames?: readonly string[];
174
268
  }): Record<string, SecretDefinition>;
175
269
 
176
- export declare interface ProviderAuthSource {
177
- /** Read the auth blob from the host, or return null when absent. */
178
- load(): string | null;
179
- /** Absolute guest path to write the blob to (mode 0600). */
180
- guestPath: string;
270
+ declare interface RequestOrigin {
271
+ hostname: string;
272
+ protocol: string;
273
+ port: number;
181
274
  }
182
275
 
183
276
  export declare function resolveVfsShadowConfig(config: SandboxConfig | undefined): {
@@ -221,44 +314,6 @@ export declare interface ResumeCommandWhen {
221
314
  */
222
315
  export declare function resumeVm(config: VmConfig): Promise<ManagedVm>;
223
316
 
224
- /**
225
- * Rewrite host-absolute paths inside an agent gitconfig to VM-local
226
- * equivalents before injecting it into the guest.
227
- *
228
- * Two rewrites:
229
- * - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
230
- * - `... credential-helper --credentials <host moltnet.json>`
231
- * → `<vmAgentDir>/moltnet.json`
232
- *
233
- * The credential-helper line is generated host-side by `moltnet github setup`
234
- * with a host-absolute `--credentials` path; inside the guest that path is
235
- * invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
236
- * rewrite rule and every other line are workspace-independent and pass through
237
- * unchanged. A gitconfig without a credential helper is rewritten only for
238
- * `signingKey`.
239
- *
240
- * This is the single source of truth for git push auth in the guest: the
241
- * injected gitconfig carries the tokenless mint-on-demand helper, so the VM
242
- * no longer hand-rolls a credential-helper script or runs an imperative
243
- * `git config --global ... insteadOf` against the guest $HOME.
244
- */
245
- export declare function rewriteGitconfigPaths(gitconfig: string, vmSshDir: string, vmAgentDir: string): string;
246
-
247
- /**
248
- * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
249
- *
250
- * Fields rewritten:
251
- * ssh.private_key_path → <vmSshDir>/<basename of original>
252
- * ssh.public_key_path → <vmSshDir>/<basename of original>
253
- * git.config_path → <vmAgentDir>/gitconfig
254
- * github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
255
- *
256
- * All other fields are passed through unchanged.
257
- * Throws if moltnetJson is not valid JSON — callers must not inject a broken
258
- * moltnet.json into the guest.
259
- */
260
- export declare function rewriteMoltnetJsonPaths(moltnetJson: string, vmAgentDir: string, vmSshDir: string, githubAppPemFilename: string | null): string;
261
-
262
317
  export declare interface SandboxConfig {
263
318
  /**
264
319
  * Operator-owned snapshot build settings. Runtime profiles must never
@@ -348,12 +403,6 @@ export declare interface VmConfig {
348
403
  checkpointPath: string;
349
404
  /** MoltNet agent name (used to resolve credentials). */
350
405
  agentName: string;
351
- /**
352
- * Trust boundary for guest credentials. `guest-config` injects the complete
353
- * legacy agent directory. `host-authenticated` never reads or injects it and
354
- * relies exclusively on the supplied host-side Agent for MoltNet operations.
355
- */
356
- guestCredentialMode?: GuestCredentialMode;
357
406
  /**
358
407
  * Host root that owns `.moltnet/<agentName>/`.
359
408
  *
@@ -383,49 +432,52 @@ export declare interface VmConfig {
383
432
  * raw values or select host secret-provider coordinates.
384
433
  */
385
434
  brokeredSecrets?: readonly BrokeredHttpSecretBinding[];
435
+ /**
436
+ * Trusted-host origins answered in-process by the proxy. Keys are full
437
+ * origins (`https://name.moltnet.internal`). Like brokered secrets these are
438
+ * outside SandboxConfig: a remotely stored profile cannot register one.
439
+ */
440
+ hostOrigins?: Record<string, HostOriginHandler>;
441
+ /**
442
+ * Trusted-host guest projection: env merged last (after broker
443
+ * placeholders), files written before the session starts, services run in
444
+ * the guest for the session's lifetime. Not subject to the profile env
445
+ * reserved-name guard — the runtime, not the profile, declares it.
446
+ */
447
+ guestProjection?: GuestProjectionInput;
386
448
  /** Structured credential-boundary diagnostics for daemon loggers. */
387
449
  onDiagnostic?: (diagnostic: VmDiagnostic) => void;
388
450
  /** Abort resume/setup work, closing any live VM owned by resumeVm. */
389
451
  signal?: AbortSignal;
390
- /**
391
- * Coding-agent provider authentication to place in the guest (for example
392
- * Pi's `auth.json`). The sandbox knows nothing about the provider: the
393
- * runtime above it supplies the loader and the guest path. When omitted,
394
- * nothing is written and the guest relies on forwarded env providers.
395
- */
396
- providerAuth?: ProviderAuthSource;
397
452
  }
398
453
 
399
454
  export declare interface VmCredentials {
400
- /** Empty in host-authenticated mode; retained as strings for API stability. */
401
- moltnetJson: string;
402
- /** Empty in host-authenticated mode; retained as strings for API stability. */
403
- agentEnvRaw: string;
404
455
  /**
405
- * Provider auth blob supplied by `VmConfig.providerAuth`, or null when the
406
- * runtime supplied none or the host file is absent — the guest then relies
407
- * on env-var providers (`ANTHROPIC_API_KEY`, etc.) carried via `agentEnv`
408
- * 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.
409
459
  */
410
- providerAuthJson: string | null;
411
460
  agentEnv: Record<string, string | undefined>;
412
- gitconfig: string | null;
413
- sshPrivateKey: string | null;
414
- sshPublicKey: string | null;
415
- allowedSigners: string | null;
416
- /** Raw PEM content of the GitHub App private key, or null if not configured. */
417
- githubAppPem: string | null;
418
- /** VM-local filename for the GitHub App PEM (basename of host path), or null. */
419
- githubAppPemFilename: string | null;
420
461
  }
421
462
 
422
463
  export declare interface VmDiagnostic {
423
- event: 'vm.credentials.mode' | 'vm.credentials.github_key_missing' | 'vm.http_secrets.bound';
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';
424
465
  level: 'info' | 'warning';
425
466
  message: string;
426
- credentialMode: GuestCredentialMode;
427
467
  /** Present only for the value-free broker summary event. */
428
468
  brokeredSecretCount?: number;
469
+ /** Present only for the host-origins summary event. */
470
+ hostOriginCount?: number;
471
+ /** Present only for the guest-projection summary event. */
472
+ projectedFileCount?: number;
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
+ };
429
481
  }
430
482
 
431
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, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
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 { parseEnv } from "node:util";
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.
@@ -100,6 +232,7 @@ var BASE_ALPINE_PACKAGE_EXECUTABLES = {
100
232
  file: "file",
101
233
  git: "git",
102
234
  jq: "jq",
235
+ "openssh-keygen": "ssh-keygen",
103
236
  ripgrep: "rg",
104
237
  tar: "tar",
105
238
  xz: "xz"
@@ -292,6 +425,35 @@ function pruneOldSnapshots(maxCached, currentDir) {
292
425
  });
293
426
  }
294
427
  //#endregion
428
+ //#region src/host-origins.ts
429
+ function hostOriginHostnames(origins) {
430
+ return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
431
+ }
432
+ function blocked() {
433
+ return new Response(JSON.stringify({
434
+ code: "host_origin_blocked",
435
+ message: "origin not served"
436
+ }), {
437
+ status: 421,
438
+ headers: { "content-type": "application/json" }
439
+ });
440
+ }
441
+ /**
442
+ * Serve a registered origin in-process. Fails closed for any request whose
443
+ * hostname belongs to a virtual origin but whose exact origin (scheme, host,
444
+ * port) is not registered: such a request must never fall through to normal
445
+ * outbound proxy forwarding (an SSRF route to a host-controlled name).
446
+ */
447
+ function createHostOriginsOnRequest(origins) {
448
+ const virtualHostnames = new Set(hostOriginHostnames(origins));
449
+ return async (request) => {
450
+ const url = new URL(request.url);
451
+ const handler = origins[url.origin];
452
+ if (handler) return handler(request);
453
+ if (virtualHostnames.has(url.hostname)) return blocked();
454
+ };
455
+ }
456
+ //#endregion
295
457
  //#region src/vm-manager.ts
296
458
  /**
297
459
  * Memory-backed VFS mount used by the daemon to inject task context
@@ -314,6 +476,12 @@ function pruneOldSnapshots(maxCached, currentDir) {
314
476
  * investigation and the alternatives we rejected.
315
477
  */
316
478
  var GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
479
+ /** Guest directory holding one PID file per projected service. */
480
+ var GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
481
+ var GUEST_SERVICE_ID_RE = /^[a-z][a-z0-9-]{0,62}$/;
482
+ function assertGuestServiceId(id) {
483
+ if (!GUEST_SERVICE_ID_RE.test(id)) throw new Error(`Invalid guest service id "${id}": expected ${GUEST_SERVICE_ID_RE}`);
484
+ }
317
485
  /** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
318
486
  var GUEST_TASK_SKILLS_MOUNT = GUEST_TASK_CONTEXT_MOUNT;
319
487
  function resolveVfsShadowConfig(config) {
@@ -396,59 +564,8 @@ function resolveVmAgentDir(config) {
396
564
  const rootDir = config.agentRootDir ?? findMainWorktree();
397
565
  return path.join(rootDir, ".moltnet", config.agentName);
398
566
  }
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
- };
567
+ function loadCredentials() {
568
+ return { agentEnv: {} };
452
569
  }
453
570
  /**
454
571
  * Apply agent env vars to the host process, mirroring `moltnet start`.
@@ -544,9 +661,13 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
544
661
  "prototype"
545
662
  ]);
546
663
  function normalizeHostPattern(pattern) {
547
- return pattern.trim().toLowerCase();
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;
548
668
  }
549
669
  function isValidHostPattern(pattern) {
670
+ if (isIP(pattern) !== 0) return true;
550
671
  return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
551
672
  }
552
673
  /**
@@ -581,8 +702,8 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
581
702
  });
582
703
  }
583
704
  function hostMatchesPattern(hostname, pattern) {
584
- const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
585
- 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));
586
707
  }
587
708
  /**
588
709
  * Conservative subset check for Gondolin hostname globs. Exact secret hosts
@@ -654,6 +775,19 @@ function headersContainSecretValue(headers, value) {
654
775
  }
655
776
  return false;
656
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
+ }
657
791
  function createBrokeredHttpSecretOriginPolicy(bindings) {
658
792
  const entries = /* @__PURE__ */ new Map();
659
793
  for (const binding of bindings) {
@@ -670,17 +804,11 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
670
804
  }
671
805
  return {
672
806
  isRequestAllowed(request) {
673
- let url;
674
- try {
675
- url = new URL(request.url);
676
- } catch {
677
- return false;
678
- }
679
- const protocol = url.protocol.slice(0, -1);
680
- const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
807
+ const requestOrigin = parseRequestOrigin(request.url);
808
+ if (!requestOrigin) return false;
681
809
  for (const entry of entries.values()) {
682
810
  if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
683
- if (protocol !== entry.protocol || !entry.ports.includes(port) || !entry.hosts.some((host) => hostMatchesPattern(url.hostname, host))) return false;
811
+ if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
684
812
  }
685
813
  return true;
686
814
  },
@@ -697,18 +825,51 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
697
825
  }
698
826
  };
699
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
+ }
700
864
  function assertGuestEnvironmentBoundary(options) {
701
- const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || options.guestCredentialMode === "host-authenticated" && !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
865
+ const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
702
866
  const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
703
867
  const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
704
868
  if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
705
869
  }
706
- /** @deprecated Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
870
+ /** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
707
871
  function assertHostAuthenticatedGuestEnvironment(options) {
708
- assertGuestEnvironmentBoundary({
709
- guestCredentialMode: "host-authenticated",
710
- ...options
711
- });
872
+ assertGuestEnvironmentBoundary(options);
712
873
  }
713
874
  /**
714
875
  * Return whether two Gondolin hostname globs can match at least one common
@@ -752,6 +913,10 @@ function assertInternalHostsDoNotOverlapProtectedHosts(internalHosts, protectedH
752
913
  * surface immediately rather than fall through to cryptic agent
753
914
  * errors later.
754
915
  */
916
+ /** Single-quote a POSIX shell argument (paths/ids are pre-validated). */
917
+ function shellQuote(value) {
918
+ return `'${value.replaceAll("'", `'\\''`)}'`;
919
+ }
755
920
  async function vmRun(vm, label, command, signal) {
756
921
  const wrapped = `set -eu\nset -o pipefail\n${command}`;
757
922
  throwIfAborted(signal, `resume step "${label}"`);
@@ -781,22 +946,17 @@ async function resumeVm(config) {
781
946
  throwIfAborted(config.signal, "VM resume");
782
947
  const agentDir = resolveVmAgentDir(config);
783
948
  const guestWorkspace = path.resolve(config.mountPath);
784
- const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
785
- if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
786
949
  assertGuestEnvironmentBoundary({
787
- guestCredentialMode,
788
950
  forwardEnv: config.forwardEnv,
789
951
  sandboxEnv: config.sandboxConfig?.env
790
952
  });
791
953
  config.onDiagnostic?.({
792
954
  event: "vm.credentials.mode",
793
955
  level: "info",
794
- credentialMode: guestCredentialMode,
795
- 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"
796
957
  });
797
- const creds = loadCredentials(agentDir, guestCredentialMode, config.onDiagnostic, config.providerAuth);
798
- const configuredApiUrl = creds.moltnetJson ? JSON.parse(creds.moltnetJson).endpoints.api : void 0;
799
- 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;
800
960
  const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
801
961
  const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
802
962
  const protectedExternalHosts = [...new Set([
@@ -806,9 +966,14 @@ async function resumeVm(config) {
806
966
  ])];
807
967
  assertInternalHostsDoNotOverlapProtectedHosts(runtimeAllowedInternalHosts, protectedExternalHosts);
808
968
  const allowedHosts = [...new Set([...protectedExternalHosts, ...runtimeAllowedHosts])];
969
+ const hostOrigins = config.hostOrigins ?? {};
970
+ const hostOriginHosts = hostOriginHostnames(hostOrigins);
971
+ assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
972
+ const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
973
+ const projectedEnv = config.guestProjection?.env ?? {};
809
974
  const brokeredSecrets = prepareBrokeredHttpSecrets({
810
975
  bindings: config.brokeredSecrets,
811
- allowedHosts: [...allowedHosts, ...runtimeAllowedInternalHosts],
976
+ allowedHosts: [...allowedHosts, ...allowedInternalHosts],
812
977
  occupiedGuestEnvNames: [
813
978
  "PATH",
814
979
  "HOME",
@@ -817,17 +982,28 @@ async function resumeVm(config) {
817
982
  "MOLTNET_GUEST_WORKSPACE",
818
983
  ...config.forwardEnv ?? [],
819
984
  ...Object.keys(creds.agentEnv),
820
- ...Object.keys(config.sandboxConfig?.env ?? {})
985
+ ...Object.keys(config.sandboxConfig?.env ?? {}),
986
+ ...Object.keys(projectedEnv)
821
987
  ]
822
988
  });
823
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
+ } });
824
998
  const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
825
999
  allowedHosts,
826
- allowedInternalHosts: runtimeAllowedInternalHosts,
1000
+ allowedInternalHosts,
827
1001
  ...Object.keys(brokeredSecrets).length > 0 && {
828
1002
  secrets: brokeredSecrets,
829
- isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
830
- }
1003
+ isRequestAllowed: (request) => brokeredNetworkOriginPolicy.isRequestAllowed(request) && brokeredSecretOriginPolicy.isRequestAllowed(request),
1004
+ isIpAllowed: brokeredNetworkOriginPolicy.isIpAllowed
1005
+ },
1006
+ ...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
831
1007
  });
832
1008
  const secretManager = Object.freeze({
833
1009
  rotateSecret(guestEnv, value) {
@@ -843,19 +1019,15 @@ async function resumeVm(config) {
843
1019
  if (brokeredSecretCount > 0) config.onDiagnostic?.({
844
1020
  event: "vm.http_secrets.bound",
845
1021
  level: "info",
846
- credentialMode: guestCredentialMode,
847
1022
  brokeredSecretCount,
848
1023
  message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
849
1024
  });
850
- const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
851
- const vmAgentEnv = {};
852
- for (const [k, v] of Object.entries(creds.agentEnv)) {
853
- if (v === void 0 || v === "") continue;
854
- if (k === "GIT_CONFIG_GLOBAL") vmAgentEnv[k] = `${vmAgentDir}/gitconfig`;
855
- else if (k.endsWith("_PRIVATE_KEY_PATH")) vmAgentEnv[k] = `${vmAgentDir}/${path.basename(v)}`;
856
- else vmAgentEnv[k] = v;
857
- }
858
- if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
1025
+ if (hostOriginHosts.length > 0) config.onDiagnostic?.({
1026
+ event: "vm.host_origins.bound",
1027
+ level: "info",
1028
+ hostOriginCount: hostOriginHosts.length,
1029
+ message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
1030
+ });
859
1031
  const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
860
1032
  let workspaceProvider = new RealFSProvider(config.mountPath);
861
1033
  workspaceProvider = new ShadowProvider(workspaceProvider, {
@@ -871,7 +1043,7 @@ async function resumeVm(config) {
871
1043
  writeMode: vfsConfig.mode
872
1044
  });
873
1045
  }
874
- if (guestCredentialMode === "host-authenticated") workspaceProvider = new ShadowProvider(workspaceProvider, {
1046
+ workspaceProvider = new ShadowProvider(workspaceProvider, {
875
1047
  shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
876
1048
  denySymlinkBypass: true,
877
1049
  writeMode: "deny"
@@ -884,7 +1056,6 @@ async function resumeVm(config) {
884
1056
  }
885
1057
  const envOverrides = config.sandboxConfig?.env ?? {};
886
1058
  const vmEnv = {
887
- ...vmAgentEnv,
888
1059
  ...forwardedEnv,
889
1060
  PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
890
1061
  HOME: "/home/agent",
@@ -892,7 +1063,8 @@ async function resumeVm(config) {
892
1063
  NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
893
1064
  ...envOverrides,
894
1065
  MOLTNET_GUEST_WORKSPACE: guestWorkspace,
895
- ...secretEnv
1066
+ ...secretEnv,
1067
+ ...projectedEnv
896
1068
  };
897
1069
  const resources = config.sandboxConfig?.resources;
898
1070
  const workspaceMode = config.workspaceMode ?? "shared_mount";
@@ -915,6 +1087,27 @@ async function resumeVm(config) {
915
1087
  process.stderr.write(`[vm] aborted resume late vm.close() failed: ${message}\n`);
916
1088
  }
917
1089
  });
1090
+ const servicesAbort = new AbortController();
1091
+ const serviceHandles = [];
1092
+ const serviceIds = [];
1093
+ const services = { async stop() {
1094
+ if (serviceIds.length > 0) {
1095
+ const pidFiles = serviceIds.map((id) => `${GUEST_SERVICE_PID_DIR}/${id}.pid`).join(" ");
1096
+ try {
1097
+ await vm.exec([
1098
+ "sh",
1099
+ "-c",
1100
+ `for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -TERM -- "-\$pid" 2>/dev/null || kill -TERM "\$pid" 2>/dev/null || true; done; sleep 0.2; for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -KILL -- "-\$pid" 2>/dev/null || kill -KILL "\$pid" 2>/dev/null || true; rm -f "\$f"; done`
1101
+ ], {
1102
+ stdout: "ignore",
1103
+ stderr: "ignore"
1104
+ });
1105
+ } catch {}
1106
+ serviceIds.length = 0;
1107
+ }
1108
+ servicesAbort.abort();
1109
+ await Promise.allSettled(serviceHandles);
1110
+ } };
918
1111
  try {
919
1112
  await vmRun(vm, "TLS certificates", `
920
1113
  cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
@@ -947,59 +1140,92 @@ async function resumeVm(config) {
947
1140
  }
948
1141
  if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
949
1142
  }
950
- const vmSshDir = `${vmAgentDir}/ssh`;
951
- const hasAgentFiles = guestCredentialMode === "guest-config";
952
- const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
953
- const guestDirs = [...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [], ...providerAuthDir ? [providerAuthDir] : []];
954
- if (guestDirs.length > 0) await vm.exec(`mkdir -p ${guestDirs.join(" ")}`, { signal: config.signal });
955
- if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
956
- mode: 384,
957
- signal: config.signal
958
- });
959
- if (hasAgentFiles) {
960
- const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
961
- await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
962
- mode: 384,
1143
+ const projectedFiles = config.guestProjection?.files ?? [];
1144
+ const projectedDirs = [...new Set(projectedFiles.map((file) => path.posix.dirname(file.path)))];
1145
+ const guestDirs = [...projectedDirs];
1146
+ if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
1147
+ for (const file of projectedFiles) {
1148
+ await vm.fs.writeFile(file.path, file.content, {
1149
+ mode: file.mode ?? 420,
963
1150
  signal: config.signal
964
1151
  });
965
- await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, {
966
- mode: 384,
967
- signal: config.signal
1152
+ if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
1153
+ }
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);
1155
+ const projectedServices = config.guestProjection?.services ?? [];
1156
+ if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
1157
+ for (const service of projectedServices) {
1158
+ assertGuestServiceId(service.id);
1159
+ const handle = vm.exec([
1160
+ "setsid",
1161
+ "sh",
1162
+ "-c",
1163
+ `echo $$ > ${GUEST_SERVICE_PID_DIR}/${service.id}.pid && exec "$@"`,
1164
+ "moltnet-service",
1165
+ ...service.command
1166
+ ], {
1167
+ stdout: "ignore",
1168
+ stderr: "ignore",
1169
+ ...service.env && { env: service.env },
1170
+ signal: servicesAbort.signal
968
1171
  });
969
- if (creds.gitconfig) {
970
- const vmGitconfig = rewriteGitconfigPaths(creds.gitconfig, vmSshDir, vmAgentDir);
971
- await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, {
972
- mode: 420,
1172
+ serviceHandles.push(Promise.resolve(handle).catch(() => void 0));
1173
+ serviceIds.push(service.id);
1174
+ }
1175
+ async function awaitServiceReady(service) {
1176
+ if (!service.readiness) return;
1177
+ const deadline = Date.now() + (service.readiness.timeoutMs ?? 1e4);
1178
+ const pidFile = `${GUEST_SERVICE_PID_DIR}/${service.id}.pid`;
1179
+ let ready = false;
1180
+ while (Date.now() < deadline) {
1181
+ throwIfAborted(config.signal, `service "${service.id}" readiness`);
1182
+ if ((await vm.exec([
1183
+ "sh",
1184
+ "-c",
1185
+ "[ -e \"$1\" ] && [ -r \"$2\" ] && kill -0 \"$(cat \"$2\" 2>/dev/null)\" 2>/dev/null",
1186
+ "moltnet-readiness",
1187
+ service.readiness.path,
1188
+ pidFile
1189
+ ], {
1190
+ stdout: "ignore",
1191
+ stderr: "ignore",
973
1192
  signal: config.signal
1193
+ })).exitCode === 0) {
1194
+ ready = true;
1195
+ break;
1196
+ }
1197
+ await new Promise((resolve) => {
1198
+ setTimeout(resolve, 200);
1199
+ });
1200
+ }
1201
+ if (!ready) {
1202
+ if (service.readiness.required) throw new Error(`Projected guest service "${service.id}" did not become ready: ${service.readiness.path} absent or its process exited`);
1203
+ config.onDiagnostic?.({
1204
+ event: "vm.guest_service.not_ready",
1205
+ level: "warning",
1206
+ message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
974
1207
  });
975
1208
  }
976
- if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
977
- mode: 384,
978
- signal: config.signal
979
- });
980
- if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
981
- mode: 420,
982
- signal: config.signal
983
- });
984
- if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
985
- mode: 420,
986
- signal: config.signal
987
- });
988
- if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
989
- mode: 384,
990
- signal: config.signal
991
- });
992
1209
  }
993
- await vm.exec(hasAgentFiles ? "chown -R agent:agent /home/agent/.pi /home/agent/.moltnet" : "chown -R agent:agent /home/agent/.pi", { signal: config.signal });
1210
+ await Promise.all(projectedServices.map((service) => awaitServiceReady(service)));
1211
+ if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
1212
+ event: "vm.guest_projection.applied",
1213
+ level: "info",
1214
+ projectedFileCount: projectedFiles.length,
1215
+ projectedServiceCount: projectedServices.length,
1216
+ message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
1217
+ });
994
1218
  return {
995
1219
  vm,
996
1220
  credentials: creds,
997
1221
  secretManager,
1222
+ services,
998
1223
  mountPath: config.mountPath,
999
1224
  guestWorkspace,
1000
1225
  agentDir
1001
1226
  };
1002
1227
  } catch (err) {
1228
+ servicesAbort.abort();
1003
1229
  try {
1004
1230
  await vm.close();
1005
1231
  } catch (closeErr) {
@@ -1009,66 +1235,5 @@ async function resumeVm(config) {
1009
1235
  throw err;
1010
1236
  }
1011
1237
  }
1012
- /**
1013
- * Rewrite host-absolute paths inside an agent gitconfig to VM-local
1014
- * equivalents before injecting it into the guest.
1015
- *
1016
- * Two rewrites:
1017
- * - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
1018
- * - `... credential-helper --credentials <host moltnet.json>`
1019
- * → `<vmAgentDir>/moltnet.json`
1020
- *
1021
- * The credential-helper line is generated host-side by `moltnet github setup`
1022
- * with a host-absolute `--credentials` path; inside the guest that path is
1023
- * invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
1024
- * rewrite rule and every other line are workspace-independent and pass through
1025
- * unchanged. A gitconfig without a credential helper is rewritten only for
1026
- * `signingKey`.
1027
- *
1028
- * This is the single source of truth for git push auth in the guest: the
1029
- * injected gitconfig carries the tokenless mint-on-demand helper, so the VM
1030
- * no longer hand-rolls a credential-helper script or runs an imperative
1031
- * `git config --global ... insteadOf` against the guest $HOME.
1032
- */
1033
- function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
1034
- return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
1035
- }
1036
- /**
1037
- * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
1038
- *
1039
- * Fields rewritten:
1040
- * ssh.private_key_path → <vmSshDir>/<basename of original>
1041
- * ssh.public_key_path → <vmSshDir>/<basename of original>
1042
- * git.config_path → <vmAgentDir>/gitconfig
1043
- * github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
1044
- *
1045
- * All other fields are passed through unchanged.
1046
- * Throws if moltnetJson is not valid JSON — callers must not inject a broken
1047
- * moltnet.json into the guest.
1048
- */
1049
- function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
1050
- const config = JSON.parse(moltnetJson);
1051
- if (config.ssh && typeof config.ssh === "object") {
1052
- const ssh = config.ssh;
1053
- const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
1054
- const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
1055
- config.ssh = {
1056
- ...ssh,
1057
- ...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
1058
- ...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
1059
- };
1060
- }
1061
- if (config.git && typeof config.git === "object") {
1062
- const git = { ...config.git };
1063
- git.config_path = `${vmAgentDir}/gitconfig`;
1064
- config.git = git;
1065
- }
1066
- if (githubAppPemFilename && config.github && typeof config.github === "object") {
1067
- const github = { ...config.github };
1068
- github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
1069
- config.github = github;
1070
- }
1071
- return JSON.stringify(config);
1072
- }
1073
1238
  //#endregion
1074
- export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, rewriteGitconfigPaths, rewriteMoltnetJsonPaths, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
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.2.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.9.1"
50
+ "@earendil-works/gondolin": "^0.12.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.19.0",