feat: add Claude Code agent - #7
Merged
Merged
Conversation
Register a `claude` agent alongside `cursor` so the same Given/When/Then steps can drive Claude Code runs. Closes #3. Agent binding (src/agents/claude/, docker/claude/Dockerfile) - Image installs the Claude Code native binary on agent-gwt/base:local, relocated to /usr/local/bin so any host uid can run it. Auto-update, telemetry and error reporting are disabled before any build-time invocation. ~/.claude is pre-created so a read-only credentials mount cannot leave it root-owned. - Runs `claude -p --output-format json --dangerously-skip-permissions [--model ..] -- <prompt>` as the host uid/gid with only the workspace mounted. `--bare` is deliberately not used: it disables OAuth, which subscription users need. - Credentials resolve in order: CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`), ANTHROPIC_API_KEY, then a Linux host's ~/.claude/.credentials.json mounted read-only. macOS keeps the token in the Keychain, so env is the only option there. Secrets are forwarded to `docker run` by name (`-e NAME`) through the docker CLI's environment and never appear on the host argv. - Result JSON is typed as ClaudeAgentResult; a run reporting `is_error: true` throws with Claude's own message so the real cause surfaces instead of a downstream assertion. Shared changes - buildDockerRunArgs gains `envPassthrough` (`-e NAME`) and runDocker accepts `env` for the docker CLI process. - New agentRunError formats non-zero `docker run` exits for both agents, with a build hint naming the image and `buildAgentImage("<name>")`. Cursor's copy pointed at a `docker:build` script that does not exist. - CONTAINER_HOME / CONTAINER_WORKSPACE move to agents/base; the public barrel exports them from there. Same names and values. - docker/base: enable pacman's DisableSandboxSyscalls. The seccomp part of pacman 7's download sandbox cannot load under Rosetta/QEMU emulation (Docker Desktop on Apple Silicon), which broke every image build there. Upstream already disables the Landlock part; the alpm user drop and NO_NEW_PRIVS are kept, and native x86_64 hosts see no practical difference. A grep guard fails the build loudly if the config line is ever renamed. Docs and tests - README: Claude auth prerequisites and usage beside the existing Cursor examples, ClaudeAgentResult, Apple Silicon note (DOCKER_DEFAULT_PLATFORM=linux/amd64). CONTRIBUTING: agents/claude row and the pnpm pr-tag cache tip. - Specs mirror the Cursor patterns: claude/run.spec.ts (args, credential resolution, secret handling, error surfacing), run-error.spec.ts, and curried steps in agent.spec.ts / build-agent-image.spec.ts covering both agents. Verified end to end on Apple Silicon: `buildAgentImage("claude")` builds both images, and a vitest-gwt suite (Sonnet, OAuth token) writes a file into the workspace and returns a typed result.
The e2e/ folder went in as a Claude-only scratch suite. Make it a proper
end-to-end suite that covers Cursor and Claude the same way:
- e2e/credentials.ts: hasCursorCredential() and hasClaudeCredential(),
the same checks the run functions make, as sync yes/no answers.
- global-setup builds the image for each agent whose credential is
present and prints a notice for the rest.
- cursor.spec.ts and claude.spec.ts run the same "writes the readme"
scenario with shared then-steps (e2e/steps.ts); only agent({ name })
and the result assertion differ. Each suite is wrapped in
describe.skipIf, so a host without that credential reports
"skipped", exit 0.
- `pnpm run test:e2e` (wireit) runs it. Unit tests, lint and the
published package are unaffected: e2e/ stays outside src/ and the
`files` list.
- CONTRIBUTING documents the requirements per agent, plus
DOCKER_DEFAULT_PLATFORM=linux/amd64 on Apple Silicon.
devzeebo
requested changes
Aug 30, 2026
|
|
||
| const tempRoots: string[] = []; | ||
|
|
||
| afterEach(async () => { |
Collaborator
There was a problem hiding this comment.
put tempRoots in context and use a withAspect(undefined, function(this: Context){})
| }; | ||
|
|
||
| /** Env OAuth token, then env API key, then a readable host credentials file. */ | ||
| export async function resolveClaudeCredentials( |
Collaborator
There was a problem hiding this comment.
split to separate _resolveCredentials.ts and split tests to separate test file
| return { kind: "credentials-file", file }; | ||
| } | ||
|
|
||
| export function buildClaudeDockerArgs(options: { |
Collaborator
There was a problem hiding this comment.
yep this should split out to _buildDockerArgs.ts as well. same with the cursor agent
matt-wright-dev
added a commit
that referenced
this pull request
Aug 30, 2026
One function per module, underscore prefix for internal modules, one spec per module. index.ts still decides the public surface, so nothing exported changes name and there is no behaviour change. claude/ - _resolveCredentials.ts (+ spec): resolveClaudeCredentials and the ClaudeCredentials type. The spec keeps its temp home on the context and cleans it up through withAspect(a_temp_home, remove_temp_home) instead of a module-level tempRoots array and afterEach. - _buildDockerArgs.ts (+ spec): buildClaudeDockerArgs. - _credentialsEnv.ts: credentialsEnv, now shared by _buildDockerArgs and run (was private to run.ts). - run.ts / run.spec.ts: runClaudeInDocker and its error helpers only. cursor/ - _buildDockerArgs.ts (+ spec): buildDockerArgs. - run.ts / run.spec.ts: runCursorInDocker only. Spec bodies were moved, not rewritten. Verified with the unit suite (52 tests, 16 files), the repo e2e, and a consumer project running the packed tarball against both agents. Refs: #7 (comment) #7 (comment) #7 (comment)
matt-wright-dev
added a commit
that referenced
this pull request
Aug 30, 2026
This reverts commit aa0ee15c which reached main by a direct push instead of a pull request. It re-lands through a PR from refactor/split-agent-bindings so review and CI run as usual.
3 tasks
devzeebo
added a commit
that referenced
this pull request
Aug 30, 2026
refactor: split agent bindings per review on #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3.
Adds a
claudeagent next tocursor, same layout (src/agents/claude/+docker/claude/), so the existing Given/When/Then steps work for Claude Code without changes.Checklist from #3
claudeis inagentRegistry(AgentNameis now"cursor" | "claude")docker/claude/Dockerfilebuilds onagent-gwt/base:local. The Claude Code binary is moved to/usr/local/binso any host uid can run it, and auto-update / telemetry / error reporting are turned offrunmounts only credentials, runs as the host uid/gid, and puts the parsed JSON onthis.agentResult. If the result saysis_error: trueit throws with Claude's own messagebuildAgentImage("claude")works from vitestglobalSetupclaude/run.spec.ts,run-error.spec.ts, plus curried steps inagent.spec.ts/build-agent-image.spec.tsthat cover both agents). 52 tests, lint andvp packcleanCredentials
Cursor mounts one
auth.json. Claude Code on macOS keeps its login in the Keychain, so there's nothing to mount. Resolution order:CLAUDE_CODE_OAUTH_TOKEN(fromclaude setup-token, subscription)ANTHROPIC_API_KEY~/.claude/.credentials.jsonon Linux hosts, mounted read-onlyEnv-based secrets go to
docker runby name (-e NAME) via the docker CLI's environment, so the value is never on the host command line. I skipped--bareon purpose: it disables OAuth, which subscription users need.Shared changes to look at
buildDockerRunArgshas a newenvPassthroughoption, andrunDockertakesenvfor the docker CLI process.agentRunErrorformats non-zerodocker runexits for both agents. Cursor's version pointed at apnpm run docker:buildscript that doesn't exist. Both now hintbuildAgentImage("<name>")and name the image.CONTAINER_HOME/CONTAINER_WORKSPACEmoved toagents/base/. The public barrel exports them from there, same names and values.Two things I'd like a second opinion on
1.
docker/base/Dockerfileturns on pacman'sDisableSandboxSyscalls. pacman 7 sandboxes its downloader: drop to thealpmuser,NO_NEW_PRIVS, a Landlock filesystem rule, and a seccomp syscall denylist. Under Rosetta/QEMU emulation every seccomp call returnsEINVAL(checked with a libseccomp probe;--privilegedandseccomp=unconfinedmake no difference), so nothing could build on Apple Silicon. Upstreamarchlinux-dockeralready shipsDisableSandboxFilesystembecause container kernels don't have Landlock; this flips the other one. The uid drop andNO_NEW_PRIVSstay, and native x86_64 hosts won't notice. It's in the base image becausemakepkg -si,yay, and any overlayRUN pacman ...all read/etc/pacman.conf, so a per-command--disable-sandbox-syscallswouldn't reach them. There's agrep -qguard so the build fails loudly if Arch ever renames the line.2. Apple Silicon runs under amd64 emulation. The official
archlinuximage is x86_64 only (arm64 was turned down upstream), so the README says to setDOCKER_DEFAULT_PLATFORM=linux/amd64. This was already the case for the Cursor image. I prototyped native arm64 withmenci/archlinuxarmand it works end to end (builds about 1.8x faster, runtime about the same under Rosetta), but it's the only live multi-arch Arch image and it's one person's unsigned build, so I left the base alone. Two small follow-ups if we want them: (a) pass--platform linux/amd64automatically when the Docker daemon is arm64 so the env var isn't needed, (b) an opt-inARG BASE_IMAGEfor arm64 hosts, pinned by digest, with amd64 users staying on the official image.Testing
pnpm run lint && pnpm run test && pnpm run buildpass (same gate as theprereleasejob).buildAgentImage("claude")builds base + Claude images, and avitest-gwtsuite usingagent({ name: "claude", model: "sonnet" })with aCLAUDE_CODE_OAUTH_TOKENwrites a file into the workspace and returns a typed result (3 turns, about $0.05, about 9 s).api_error: ... 401 Invalid bearer token).e2e/has the credential-gated end-to-end suite from above (pnpm exec vitest run --config e2e/vitest.e2e.config.ts). It sits outsidesrc/and the publishedfiles, so unit tests, lint, and the package don't see it.