Skip to content

Commit 169911e

Browse files
authored
Merge pull request #8 from thunderock/docker
Docker
2 parents 4427fef + 3471763 commit 169911e

63 files changed

Lines changed: 3513 additions & 800 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker-image.yml

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,71 @@ on:
1313
branches: [main]
1414
paths:
1515
- 'docker/**'
16+
- 'electron/ipc/pty.ts'
17+
- 'electron/ipc/docker-gsd-seed.ts'
18+
- 'electron/ipc/gsd-seeding.integration.test.ts'
19+
- 'package.json'
1620
- '.github/workflows/docker-image.yml'
1721
pull_request:
1822
paths:
1923
- 'docker/**'
24+
- 'electron/ipc/pty.ts'
25+
- 'electron/ipc/docker-gsd-seed.ts'
26+
- 'electron/ipc/gsd-seeding.integration.test.ts'
27+
- 'package.json'
2028
- '.github/workflows/docker-image.yml'
2129
workflow_dispatch:
2230

2331
env:
2432
IMAGE: docker.io/thunderockforge/forge-agent
2533

2634
jobs:
27-
# ---- PR verification: prove the image builds, don't push ----
35+
# ---- PR verification: build, load, and prove the codex+claude gsd bake ----
2836
verify:
2937
if: github.event_name == 'pull_request'
3038
runs-on: ubuntu-24.04
3139
steps:
3240
- uses: actions/checkout@v4
3341
- uses: docker/setup-buildx-action@v3
34-
- name: Build (amd64, no push)
42+
- name: Build (amd64, load for verification)
3543
uses: docker/build-push-action@v6
3644
with:
3745
context: ./docker
3846
platforms: linux/amd64
3947
push: false
48+
load: true
49+
tags: forge-agent:ci
50+
- name: Verify gsd bake — codex (CDX-02/CDX-03) + Claude non-regression (Pitfall 4)
51+
run: |
52+
docker run --rm --user 1000:1000 forge-agent:ci sh -c '
53+
set -e
54+
# CDX-03 (file-presence proxy): top-level gsd prompts → /prompts:gsd-*
55+
test "$(ls ~/.codex/prompts/gsd-*.md | wc -l)" -ge 20
56+
test -f ~/.codex/prompts/gsd-plan-phase.md
57+
# CDX-02: agent registrations + baked dirs
58+
grep -q "\[agents.gsd-" ~/.codex/config.toml
59+
ls -d ~/.codex/agents ~/.codex/skills
60+
# Claude non-regression (Pitfall 4): the Claude gsd layer survived the codex bake.
61+
# gsd-cc installs Claude gsd as skills (~/.claude/skills/gsd-*) + the engine
62+
# (~/.claude/get-shit-done), NOT ~/.claude/commands/gsd — that path does not exist
63+
# for this gsd version, so the old glob was a false failure.
64+
ls -d ~/.claude/skills/gsd-* >/dev/null
65+
test -d ~/.claude/get-shit-done
66+
# Informational only (RESEARCH Open Q1; also surfaces the real ~/.claude layout):
67+
ls ~/.codex ~/.claude || true
68+
'
69+
# ---- the LOAD-BEARING gate: the real runtime seed into the run-user HOME ----
70+
# The baked block above reads /home/agent (the false-pass that shipped the bug).
71+
# This runs the REAL ensureGsdSkeleton staging + GSD_SEED_ENTRYPOINT as the
72+
# runner's non-1000 uid (1001) with HOME=/home/forge and asserts the SEEDED HOME
73+
# for BOTH agents. forge-agent:ci is already in the daemon (load:true) — no pull.
74+
- uses: actions/setup-node@v4
75+
with:
76+
node-version: '22'
77+
cache: npm
78+
- run: npm ci
79+
- name: Verify runtime gsd seeding — both agents (VER-01/VER-02)
80+
run: FORGE_AGENT_IMAGE=forge-agent:ci npm run test:gsd-docker
4081

4182
# ---- main / manual: build each arch on its native runner, push by digest ----
4283
build:

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ update-test/
1313
.claude
1414
.DS_Store
1515
docs/
16+
openspec/
17+
18+
# GSD planning artifacts — kept local (commit_docs=false)
19+
.planning/
1620

1721
# Sandbox bind-mount artifacts from user home (not project files).
1822
# Root-anchored so legitimate nested files with these names are still tracked.

docker/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ RUN set -eux; \
170170
chown -R 1000:1000 /home/agent/.claude /home/agent/.gsd; \
171171
chmod -R a+rX /home/agent/.claude /home/agent/.gsd
172172

173+
# GSD Codex layer — bake gsd-codex-cli AFTER the Claude bake (above) so codex
174+
# ships with /prompts:gsd-* + [agents.gsd-*] and does not clobber Claude's newer
175+
# engine (RESEARCH Pitfall 4). Seeded into each task's per-agent $HOME/.codex at
176+
# container start (see electron/ipc/pty.ts). Pre-seed a secret-free config.toml
177+
# first so the installer's registrations have an append target (RESEARCH Pitfall 2).
178+
COPY --chown=1000:1000 dotfiles/codex-config.toml /home/agent/.codex/config.toml
179+
RUN set -eux; \
180+
HOME=/home/agent npx --yes gsd-codex-cli@1.20.13 --global; \
181+
rm -rf /home/agent/.npm; \
182+
chown -R 1000:1000 /home/agent/.codex /home/agent/.claude; \
183+
chmod -R a+rX /home/agent/.codex
184+
173185
ENV SHELL=/bin/bash
174186
WORKDIR /app
175187
CMD ["bash"]

docker/dotfiles/codex-config.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# GSD Codex configuration — pre-seeded base.
2+
# The gsd-codex-cli installer appends its [agents.gsd-*] sections below this
3+
# (RESEARCH Pitfall 2: guarantees registrations land whether the installer
4+
# creates or merges). Codex later appends [projects.*] trust entries at runtime.
5+
# Secret-free — no auth lives here.

electron/ipc/channels.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export enum IPC {
9797
StartRemoteServer = 'start_remote_server',
9898
StopRemoteServer = 'stop_remote_server',
9999
GetRemoteStatus = 'get_remote_status',
100+
GeneratePairingPin = 'generate_pairing_pin',
101+
// Mobile task creation (main ↔ renderer round-trip for paired phones)
102+
Remote_GetProjectsRequest = 'remote_get_projects_request',
103+
Remote_CreateTaskRequest = 'remote_create_task_request',
104+
Remote_RendererReply = 'remote_renderer_reply',
100105

101106
// Plan
102107
PlanContent = 'plan_content',

electron/ipc/docker-gsd-seed.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)