|
| 1 | +import { execFileSync } from 'child_process'; |
| 2 | +import fs from 'fs'; |
| 3 | +import path from 'path'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Shared, dependency-light Docker gsd-seed surface — the single source of truth. |
| 7 | + * |
| 8 | + * The load-bearing runtime-seeding facts (the container HOME, the read-only |
| 9 | + * skeleton mount path, the exact entrypoint bootstrap string, and the root-staging |
| 10 | + * one-shot) live HERE rather than inline in pty.ts, so production (spawnAgent) and |
| 11 | + * the Phase 3 in-container check import the SAME constants and cannot drift — the |
| 12 | + * exact class of silent blind spot Phase 3 exists to prevent (RESEARCH § "The DRY |
| 13 | + * Seam", Pitfall 5). |
| 14 | + * |
| 15 | + * This module imports ONLY node builtins (fs / path / child_process / process): no |
| 16 | + * Electron, no node-pty, no logger — so a plain vitest file (or a compiled node |
| 17 | + * script) can import it without pulling in the whole PTY/Electron stack. |
| 18 | + */ |
| 19 | + |
| 20 | +/** |
| 21 | + * Fixed container path for every agent's writable HOME. |
| 22 | + * |
| 23 | + * Docker tasks run as the host user's uid/gid so files created in the mounted |
| 24 | + * project worktree stay owned by the host user. On macOS that is often 501:20, |
| 25 | + * which cannot write to (nor even traverse the 0750) image-owned /home/agent |
| 26 | + * directory — and codex refuses a HOME under /tmp. So instead of /tmp we |
| 27 | + * bind-mount a per-agent host dir the run-user created (see spawnAgent) onto |
| 28 | + * this fixed path, keeping HOME writable under --user. (The baked gsd skeleton |
| 29 | + * also lives under the unreadable /home/agent, so it is staged out to a |
| 30 | + * run-user-readable /opt/forge-skel mount — see ensureGsdSkeleton.) |
| 31 | + * |
| 32 | + * The path is FIXED (shared across agents), not per-agent: each container has |
| 33 | + * its own mount namespace, so isolation comes from the unique host SOURCE dir, |
| 34 | + * not the container path. A fixed path also avoids leaking host FS layout and |
| 35 | + * keeps every credential mount at a stable, same-across-agents location. |
| 36 | + */ |
| 37 | +export const DOCKER_CONTAINER_HOME = '/home/forge'; |
| 38 | + |
| 39 | +/** |
| 40 | + * Read-only in-container mount point for the staged gsd skeleton (Design B). |
| 41 | + * |
| 42 | + * The entrypoint cp -an's from here into HOME; ensureGsdSkeleton stages the |
| 43 | + * run-user-owned host dir that backs this mount. Named constant so the seed |
| 44 | + * string (GSD_SEED_ENTRYPOINT) and the mount splice in spawnAgent share ONE |
| 45 | + * path and can never diverge. |
| 46 | + */ |
| 47 | +export const FORGE_SKEL_MOUNT = '/opt/forge-skel'; |
| 48 | + |
| 49 | +/** |
| 50 | + * The exact in-container bootstrap spawnAgent runs before exec'ing the agent |
| 51 | + * command (Design B seed). Single source of truth: pty.ts references this |
| 52 | + * constant, so the Phase 3 check runs the byte-identical string. |
| 53 | + * |
| 54 | + * Seeds the per-agent HOME from the read-only gsd skeleton staged at |
| 55 | + * FORGE_SKEL_MOUNT (the image's own /home/agent is 0750/uid-1000 and unreadable |
| 56 | + * by the run-user). cp -an is no-clobber so a shared-auth .claude bind mount keeps |
| 57 | + * its credentials, and the seed runs IN-CONTAINER after mounts so that mount is |
| 58 | + * not shadowed (RESEARCH Pitfall 3). Failures SURFACE (DOCK-04): an unwritable |
| 59 | + * HOME is FATAL (exit 1); a cp miss WARNs and continues (an empty skel dir must |
| 60 | + * still let the agent run). Byte-identical to the previous pty.ts inline literal. |
| 61 | + */ |
| 62 | +export const GSD_SEED_ENTRYPOINT = |
| 63 | + 'mkdir -p "$HOME/.claude" "$HOME/.gsd" "$HOME/.codex" || { echo "[forge] FATAL: HOME not writable ($HOME)" >&2; exit 1; }; ' + |
| 64 | + `cp -an ${FORGE_SKEL_MOUNT}/.claude/. "$HOME/.claude/" || echo "[forge] WARN: gsd .claude seed failed" >&2; ` + |
| 65 | + `cp -an ${FORGE_SKEL_MOUNT}/.gsd/. "$HOME/.gsd/" || echo "[forge] WARN: gsd .gsd seed failed" >&2; ` + |
| 66 | + `cp -an ${FORGE_SKEL_MOUNT}/.codex/. "$HOME/.codex/" || echo "[forge] WARN: gsd .codex seed failed" >&2; ` + |
| 67 | + 'exec "$@"'; |
| 68 | + |
| 69 | +/** |
| 70 | + * Resolved image-id → staged gsd-skeleton host dir. Guards the Design B root |
| 71 | + * staging one-shot so it runs at most once per image per session (a new image = |
| 72 | + * a new id = a new dir; stale dirs are ignorable). See ensureGsdSkeleton. |
| 73 | + */ |
| 74 | +const stagedSkeletons = new Map<string, string>(); |
| 75 | + |
| 76 | +/** |
| 77 | + * Resolve an image's local image-id synchronously (trimmed), or null if absent. |
| 78 | + * Same `docker image ls --filter reference=<image> --format {{.ID}}` shape as |
| 79 | + * dockerImagePresentSync, but returns the id (the gsd-skeleton staging cache key) |
| 80 | + * instead of a boolean. Bounded timeout; any failure resolves to null. |
| 81 | + */ |
| 82 | +function resolveImageIdSync(image: string): string | null { |
| 83 | + try { |
| 84 | + const out = execFileSync( |
| 85 | + 'docker', |
| 86 | + ['image', 'ls', '--filter', `reference=${image}`, '--format', '{{.ID}}'], |
| 87 | + { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] }, |
| 88 | + ); |
| 89 | + return ( |
| 90 | + out |
| 91 | + .split('\n') |
| 92 | + .map((line) => line.trim()) |
| 93 | + .find(Boolean) ?? null |
| 94 | + ); |
| 95 | + } catch { |
| 96 | + return null; |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Stage the image's baked gsd skeleton (.claude + .gsd, baked under /home/agent) |
| 102 | + * into a run-user-owned host dir and return it for a read-only /opt/forge-skel |
| 103 | + * mount. Design B (RESEARCH "The Decision Point"): the image's /home/agent is mode |
| 104 | + * 0750 owned by uid 1000, so the macOS run-user (uid 501) cannot traverse it to cp |
| 105 | + * the skeleton directly. A throwaway --user 0:0 root container (root CAN traverse) |
| 106 | + * extracts the skeleton into ~/.forge/gsd-skeleton/<imageId> and chowns it to the |
| 107 | + * run-user; the agent entrypoint then cp -an's from the readable mount into HOME. |
| 108 | + * |
| 109 | + * Blocking one-shot, cached by resolved image-id: runs at most once per image per |
| 110 | + * session — a sub-second, image-present-gated extract, so there is no need to |
| 111 | + * defer launch() like the async pull path. Best-effort: a project/stale image with |
| 112 | + * no skeleton yields an empty dir (the entrypoint WARNs, non-fatal); a docker |
| 113 | + * failure warns and still returns the (possibly empty) dir so the agent launches. |
| 114 | + */ |
| 115 | +export function ensureGsdSkeleton(image: string): string | null { |
| 116 | + const imageId = resolveImageIdSync(image); |
| 117 | + if (!imageId) return null; |
| 118 | + |
| 119 | + const cached = stagedSkeletons.get(imageId); |
| 120 | + if (cached && fs.existsSync(cached)) return cached; |
| 121 | + |
| 122 | + const skelDir = path.join(process.env.HOME ?? '', '.forge', 'gsd-skeleton', imageId); |
| 123 | + |
| 124 | + // Cross-session cache: reuse a previously-staged, populated dir as-is. |
| 125 | + try { |
| 126 | + if (fs.readdirSync(skelDir).length > 0) { |
| 127 | + stagedSkeletons.set(imageId, skelDir); |
| 128 | + return skelDir; |
| 129 | + } |
| 130 | + } catch { |
| 131 | + // Missing/unreadable — fall through to (re)stage. |
| 132 | + } |
| 133 | + |
| 134 | + try { |
| 135 | + fs.mkdirSync(skelDir, { recursive: true }); |
| 136 | + const uid = process.getuid?.() ?? 1000; |
| 137 | + const gid = process.getgid?.() ?? 1000; |
| 138 | + // Root traverses the 0750 /home/agent fine; on virtiofs its writes map to the |
| 139 | + // host user, and the chown makes ownership correct on real Linux too. The |
| 140 | + // extract is best-effort (|| true) — a missing skeleton is non-fatal HERE; |
| 141 | + // surfacing the resulting empty seed is the ENTRYPOINT's job (DOCK-04). |
| 142 | + execFileSync( |
| 143 | + 'docker', |
| 144 | + [ |
| 145 | + 'run', |
| 146 | + '--rm', |
| 147 | + '--user', |
| 148 | + '0:0', |
| 149 | + '-v', |
| 150 | + `${skelDir}:/out`, |
| 151 | + image, |
| 152 | + 'sh', |
| 153 | + '-c', |
| 154 | + `cp -a /home/agent/.claude /home/agent/.gsd /home/agent/.codex /out/ 2>/dev/null || true; chown -R ${uid}:${gid} /out 2>/dev/null || true`, |
| 155 | + ], |
| 156 | + { timeout: 60_000, stdio: 'ignore' }, |
| 157 | + ); |
| 158 | + } catch (err) { |
| 159 | + console.warn(`[docker] gsd skeleton staging failed for ${image}: ${String(err)}`); |
| 160 | + } |
| 161 | + |
| 162 | + // Record even an empty dir so the root container never re-runs per agent. |
| 163 | + stagedSkeletons.set(imageId, skelDir); |
| 164 | + return skelDir; |
| 165 | +} |
0 commit comments