Skip to content

feat: add Claude Code agent - #7

Merged
devzeebo merged 2 commits into
mainfrom
feat/claude-agent
Aug 30, 2026
Merged

feat: add Claude Code agent#7
devzeebo merged 2 commits into
mainfrom
feat/claude-agent

Conversation

@matt-wright-dev

Copy link
Copy Markdown
Collaborator

Closes #3.

Adds a claude agent next to cursor, same layout (src/agents/claude/ + docker/claude/), so the existing Given/When/Then steps work for Claude Code without changes.

Checklist from #3

  • claude is in agentRegistry (AgentName is now "cursor" | "claude")
  • docker/claude/Dockerfile builds on agent-gwt/base:local. The Claude Code binary is moved to /usr/local/bin so any host uid can run it, and auto-update / telemetry / error reporting are turned off
  • run mounts only credentials, runs as the host uid/gid, and puts the parsed JSON on this.agentResult. If the result says is_error: true it throws with Claude's own message
  • buildAgentImage("claude") works from vitest globalSetup
  • README covers auth setup and has a minimal example alongside the Cursor ones
  • Specs follow the Cursor patterns (claude/run.spec.ts, run-error.spec.ts, plus curried steps in agent.spec.ts / build-agent-image.spec.ts that cover both agents). 52 tests, lint and vp pack clean

Credentials

Cursor mounts one auth.json. Claude Code on macOS keeps its login in the Keychain, so there's nothing to mount. Resolution order:

  1. CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token, subscription)
  2. ANTHROPIC_API_KEY
  3. ~/.claude/.credentials.json on Linux hosts, mounted read-only

Env-based secrets go to docker run by name (-e NAME) via the docker CLI's environment, so the value is never on the host command line. I skipped --bare on purpose: it disables OAuth, which subscription users need.

Shared changes to look at

  • buildDockerRunArgs has a new envPassthrough option, and runDocker takes env for the docker CLI process.
  • New agentRunError formats non-zero docker run exits for both agents. Cursor's version pointed at a pnpm run docker:build script that doesn't exist. Both now hint buildAgentImage("<name>") and name the image.
  • CONTAINER_HOME / CONTAINER_WORKSPACE moved to agents/base/. The public barrel exports them from there, same names and values.

Two things I'd like a second opinion on

1. docker/base/Dockerfile turns on pacman's DisableSandboxSyscalls. pacman 7 sandboxes its downloader: drop to the alpm user, NO_NEW_PRIVS, a Landlock filesystem rule, and a seccomp syscall denylist. Under Rosetta/QEMU emulation every seccomp call returns EINVAL (checked with a libseccomp probe; --privileged and seccomp=unconfined make no difference), so nothing could build on Apple Silicon. Upstream archlinux-docker already ships DisableSandboxFilesystem because container kernels don't have Landlock; this flips the other one. The uid drop and NO_NEW_PRIVS stay, and native x86_64 hosts won't notice. It's in the base image because makepkg -si, yay, and any overlay RUN pacman ... all read /etc/pacman.conf, so a per-command --disable-sandbox-syscalls wouldn't reach them. There's a grep -q guard so the build fails loudly if Arch ever renames the line.

2. Apple Silicon runs under amd64 emulation. The official archlinux image is x86_64 only (arm64 was turned down upstream), so the README says to set DOCKER_DEFAULT_PLATFORM=linux/amd64. This was already the case for the Cursor image. I prototyped native arm64 with menci/archlinuxarm and 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/amd64 automatically when the Docker daemon is arm64 so the env var isn't needed, (b) an opt-in ARG BASE_IMAGE for arm64 hosts, pinned by digest, with amd64 users staying on the official image.

Testing

  • pnpm run lint && pnpm run test && pnpm run build pass (same gate as the prerelease job).
  • On Apple Silicon (Docker Desktop, Rosetta): buildAgentImage("claude") builds base + Claude images, and a vitest-gwt suite using agent({ name: "claude", model: "sonnet" }) with a CLAUDE_CODE_OAUTH_TOKEN writes a file into the workspace and returns a typed result (3 turns, about $0.05, about 9 s).
  • No credential and bad token both fail fast with a clear message (credential guidance / 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 outside src/ and the published files, so unit tests, lint, and the package don't see it.

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.
@matt-wright-dev matt-wright-dev added bug Something isn't working minor Next release bumps the minor version and removed bug Something isn't working labels Aug 30, 2026

const tempRoots: string[] = [];

afterEach(async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put tempRoots in context and use a withAspect(undefined, function(this: Context){})

Comment thread src/agents/claude/run.ts
};

/** Env OAuth token, then env API key, then a readable host credentials file. */
export async function resolveClaudeCredentials(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

split to separate _resolveCredentials.ts and split tests to separate test file

Comment thread src/agents/claude/run.ts
return { kind: "credentials-file", file };
}

export function buildClaudeDockerArgs(options: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep this should split out to _buildDockerArgs.ts as well. same with the cursor agent

@devzeebo
devzeebo merged commit 1768015 into main Aug 30, 2026
7 of 8 checks passed
@devzeebo
devzeebo deleted the feat/claude-agent branch August 30, 2026 14:16
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.
devzeebo added a commit that referenced this pull request Aug 30, 2026
refactor: split agent bindings per review on #7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Next release bumps the minor version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Claude agent

2 participants