feat(architect): relay chat turns via docker exec (#1132) #1135

Merged
charles merged 1 commit from code-lead/1132 into main 2026-05-13 21:33:00 +00:00
Collaborator

Summary

  • Adds DockerExecClaudeAgent, a ClaudeAgentPort adapter that relays architect chat turns through docker exec -i into the persistent claude-hooks-architect-default container (provisioned in #1131) via the existing ensureExecShim mechanism. Drop-in for SdkClaudeAgent — same streaming AsyncIterable<TaskEvent> shape, same canUseTool wiring, same session-resume contract.
  • Wires the adapter into ArchitectAgentDispatch via the existing claudeAgent?: ClaudeAgentPort injection point, so the production chat path now relays into the container while tests keep injecting fakes (no docker required).
  • Overrides CLAUDE_CONFIG_DIR to the in-container bind target so the in-container CLI reads .credentials.json from the mounted dir, not the host path the dispatch layer set.
  • Widens touchesHostSecret to catch both the in-container path (/home/claude/.config/claude-code/...) and the spec's /claude-config shorthand; the architect tool policy now denies Write/Edit on those paths too, alongside the existing Read/Bash coverage.

Closes #1132.

Test plan

  • bun test apps/server/src/infrastructure/agent/docker-exec-adapter.test.ts (10 new tests covering request shaping + TaskEvent forwarding from mocked subprocess output)
  • bun test apps/server/src/http/handlers/architect.test.ts (existing 91 tests + 5 new ones for the architect tool policy under /claude-config)
  • bun test apps/server/src/domain/agent/agent-runner.test.ts (extended touchesHostSecret table with 4 new denied cases + 1 new allowed case)
  • just qa — typecheck + biome + 3332 tests + 1137 web tests + sql/paraglide/i18n/flow checks

🤖 Generated with Claude Code

## Summary - Adds `DockerExecClaudeAgent`, a `ClaudeAgentPort` adapter that relays architect chat turns through `docker exec -i` into the persistent `claude-hooks-architect-default` container (provisioned in #1131) via the existing `ensureExecShim` mechanism. Drop-in for `SdkClaudeAgent` — same streaming `AsyncIterable<TaskEvent>` shape, same `canUseTool` wiring, same session-resume contract. - Wires the adapter into `ArchitectAgentDispatch` via the existing `claudeAgent?: ClaudeAgentPort` injection point, so the production chat path now relays into the container while tests keep injecting fakes (no docker required). - Overrides `CLAUDE_CONFIG_DIR` to the in-container bind target so the in-container CLI reads `.credentials.json` from the mounted dir, not the host path the dispatch layer set. - Widens `touchesHostSecret` to catch both the in-container path (`/home/claude/.config/claude-code/...`) and the spec's `/claude-config` shorthand; the architect tool policy now denies Write/Edit on those paths too, alongside the existing Read/Bash coverage. Closes #1132. ## Test plan - [x] `bun test apps/server/src/infrastructure/agent/docker-exec-adapter.test.ts` (10 new tests covering request shaping + `TaskEvent` forwarding from mocked subprocess output) - [x] `bun test apps/server/src/http/handlers/architect.test.ts` (existing 91 tests + 5 new ones for the architect tool policy under `/claude-config`) - [x] `bun test apps/server/src/domain/agent/agent-runner.test.ts` (extended `touchesHostSecret` table with 4 new denied cases + 1 new allowed case) - [x] `just qa` — typecheck + biome + 3332 tests + 1137 web tests + sql/paraglide/i18n/flow checks 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(architect): relay chat turns via docker exec (#1132)
All checks were successful
qa / dockerfile (pull_request) Successful in 11s
qa / sql-layer-check (pull_request) Successful in 11s
qa / db-schema (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 20s
qa / qa-1 (pull_request) Successful in 2m18s
qa / qa (pull_request) Successful in 0s
adc9dc1d19
Add `DockerExecClaudeAgent`, a `ClaudeAgentPort` adapter that runs the
architect's Claude CLI subprocess inside the `claude-hooks-architect-default`
container (provisioned in #1131) via the existing `docker exec -i` shim
(`ensureExecShim`). `ArchitectAgentDispatch` now injects this adapter on
the chat dispatch path; tests keep injecting fakes through the same
`claudeAgent?: ClaudeAgentPort` seam, so the chat surface stays
docker-free in CI.

The adapter overrides `CLAUDE_CONFIG_DIR` to the in-container bind
target (`/home/claude/.config/claude-code`, the spec's `/claude-config`
shorthand) so the in-container CLI reads `.credentials.json` from the
bind mount instead of the host path the dispatch layer set. To keep
the agent from exfiltrating or rewriting its own mounted token,
`touchesHostSecret` is widened to catch both the in-container path and
the `/claude-config` literal, and `buildArchitectRunRequest::toolPolicy`
now denies Write / Edit on those paths too (Read + Bash were already
covered).

Streaming + session resume + abort + steer all flow through unchanged:
the inner SDK adapter still owns the JSON protocol, `canUseTool` is
wired the same way, and `runArchitectTurn` reads `claude_session_id`
from SQLite and passes it as `resumeSessionId` like before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reviewer approved these changes 2026-05-13 21:32:30 +00:00
reviewer left a comment

APPROVED

CI green. All AC items from issue #1132 verified against the implementation.


AC walkthrough

AC Status Notes
DockerExecClaudeAgent implements ClaudeAgentPort docker-exec-adapter.ts — thin wrapper, delegates to inner port
Drop-in replacement (same AsyncIterable<TaskEvent> interface) yield* this.inner.runTask(wrapped) — transparent pass-through
Production wiring via claudeAgent? injection point architect-agent-dispatch.ts injects new DockerExecClaudeAgent(...)
CLAUDE_CONFIG_DIR → in-container bind target Overridden to CONTAINER_CLAUDE_CONFIG_DIR (/home/claude/.config/claude-code) before the spread
Session resume (resumeSessionId) pass-through { ...req, env, pathToExecutable: shim } preserves all other fields including resumeSessionId
Abort propagation AbortController reference spreads through untouched; inner SDK adapter handles it
Steer continues to work onSteerQueued/onSteerDelivered forwarded in ArchitectAgentDispatch.dispatch()
toolPolicy denies Read/Bash/Write/Edit under /claude-config Read+Bash coverage pre-existed; Write/Edit block added in this PR; all route through touchesHostSecret
touchesHostSecret updated for /claude-config and in-container path Regex extended; false-positive avoidance verified (/srv/claude-configurations/ not blocked)
Existing architect.test.ts tests pass CI green
New DockerExecClaudeAgent TaskEvent forwarding test Full assistant+result event round-trip in docker-exec-adapter.test.ts

Shim contract alignment

The three shim contract vars (CLAUDE_HOOKS_CONTAINER, CLAUDE_HOOKS_DOCKER_CWD, CLAUDE_HOOKS_DOCKER_ENV) match the names the ensureExecShim shell script reads verbatim (container.ts lines 270-272, 302-304). CLAUDE_CONFIG_DIR is in ARCHITECT_FORWARDED_ENV, so the -e NAME forwarding propagates the already-overridden in-container value into the container. The chain is correct end-to-end.

Regex boundary analysis

The /claude-config branch of HOST_SECRET_PATH:

(?:^|[\s'"=:;|&<>(])\/claude-config(?:\/|$|[\s'"=:;|&<>)])

The boundary character classes correctly reject v (from /srv/) as a preceding char, so /srv/claude-configurations/spec.md is not blocked while ls /claude-config and cat /claude-config/.credentials.json are. Both sides are covered by the new test cases in agent-runner.test.ts. The Write/Edit guard in buildArchitectRunRequest calling the same touchesHostSecret closes the loop — previously only Read+Bash were checked.


Non-blocking observation

resumeSessionId has no explicit adapter test. The "passes through prompt / model / abort / cwd unchanged" test checks four fields but not resumeSessionId. The correctness is obvious (...req spreads everything), but if this file is touched in a follow-up, adding resumeSessionId: "prev-session-42" to baseRequest() and asserting it survives the adapter would close the coverage gap neatly.

## APPROVED ✅ CI green. All AC items from issue #1132 verified against the implementation. --- ### AC walkthrough | AC | Status | Notes | |---|---|---| | `DockerExecClaudeAgent` implements `ClaudeAgentPort` | ✅ | `docker-exec-adapter.ts` — thin wrapper, delegates to inner port | | Drop-in replacement (same `AsyncIterable<TaskEvent>` interface) | ✅ | `yield* this.inner.runTask(wrapped)` — transparent pass-through | | Production wiring via `claudeAgent?` injection point | ✅ | `architect-agent-dispatch.ts` injects `new DockerExecClaudeAgent(...)` | | `CLAUDE_CONFIG_DIR` → in-container bind target | ✅ | Overridden to `CONTAINER_CLAUDE_CONFIG_DIR` (`/home/claude/.config/claude-code`) before the spread | | Session resume (`resumeSessionId`) pass-through | ✅ | `{ ...req, env, pathToExecutable: shim }` preserves all other fields including `resumeSessionId` | | Abort propagation | ✅ | `AbortController` reference spreads through untouched; inner SDK adapter handles it | | Steer continues to work | ✅ | `onSteerQueued`/`onSteerDelivered` forwarded in `ArchitectAgentDispatch.dispatch()` | | `toolPolicy` denies Read/Bash/Write/Edit under `/claude-config` | ✅ | Read+Bash coverage pre-existed; Write/Edit block added in this PR; all route through `touchesHostSecret` | | `touchesHostSecret` updated for `/claude-config` and in-container path | ✅ | Regex extended; false-positive avoidance verified (`/srv/claude-configurations/` not blocked) | | Existing `architect.test.ts` tests pass | ✅ | CI green | | New `DockerExecClaudeAgent` `TaskEvent` forwarding test | ✅ | Full assistant+result event round-trip in `docker-exec-adapter.test.ts` | --- ### Shim contract alignment The three shim contract vars (`CLAUDE_HOOKS_CONTAINER`, `CLAUDE_HOOKS_DOCKER_CWD`, `CLAUDE_HOOKS_DOCKER_ENV`) match the names the `ensureExecShim` shell script reads verbatim (`container.ts` lines 270-272, 302-304). `CLAUDE_CONFIG_DIR` is in `ARCHITECT_FORWARDED_ENV`, so the `-e NAME` forwarding propagates the already-overridden in-container value into the container. The chain is correct end-to-end. ### Regex boundary analysis The `/claude-config` branch of `HOST_SECRET_PATH`: ``` (?:^|[\s'"=:;|&<>(])\/claude-config(?:\/|$|[\s'"=:;|&<>)]) ``` The boundary character classes correctly reject `v` (from `/srv/`) as a preceding char, so `/srv/claude-configurations/spec.md` is not blocked while `ls /claude-config` and `cat /claude-config/.credentials.json` are. Both sides are covered by the new test cases in `agent-runner.test.ts`. The Write/Edit guard in `buildArchitectRunRequest` calling the same `touchesHostSecret` closes the loop — previously only Read+Bash were checked. --- ### Non-blocking observation **`resumeSessionId` has no explicit adapter test.** The "passes through prompt / model / abort / cwd unchanged" test checks four fields but not `resumeSessionId`. The correctness is obvious (`...req` spreads everything), but if this file is touched in a follow-up, adding `resumeSessionId: "prev-session-42"` to `baseRequest()` and asserting it survives the adapter would close the coverage gap neatly.
charles deleted branch code-lead/1132 2026-05-13 21:33:00 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/agent-hooks!1135
No description provided.