feat(cursor): run cursor-agent inside docker like claude #1032

Merged
charles merged 3 commits from feat/cursor-in-docker into main 2026-05-10 10:54:05 +00:00
Collaborator

Summary

Brings the cursor provider to feature parity with the anthropic (claude) provider's containerised dispatch. Same image, same shim, same per-agent identity, same MCP wiring. Drops @cursor/sdk from the runtime tree.

agent-runner.ts (host)
    │ pathToExecutable = ~/.cache/claude-hooks/container-exec.sh
    │ env = { CLAUDE_HOOKS_CONTAINER, CLAUDE_HOOKS_DOCKER_CWD,
    │         CLAUDE_HOOKS_CONTAINER_BIN=cursor-agent,
    │         CLAUDE_HOOKS_DOCKER_ENV=CURSOR_API_KEY ... }
    ▼
CliCursorAgent.runTask()  →  Bun.spawn([shim, "-p", "--output-format",
                                        "stream-json", "--workspace",
                                        <inContainerCwd>, ...])
    ▼
container-exec.sh  →  docker exec -i -w "$CWD" -e CURSOR_API_KEY ... \
                       claude-hooks-<agent>  /usr/local/bin/cursor-agent ...
    ▼
container `claude-hooks-<agent>`
    └─ git identity = $GIT_AUTHOR_NAME / $GIT_COMMITTER_NAME (per-agent)
       MCP servers from /home/claude/.cursor/mcp.json
       cwd = /state/worktrees/<branch>

One shim, two providers. CLAUDE_HOOKS_CONTAINER_BIN env var picks which binary the shim execs (defaults to claude for back-compat). Cursor path sets it to cursor-agent.

Changes

Image (Dockerfile) — install cursor-agent next to claude from the official downloads.cursor.com tarball, pinned by ARG CURSOR_AGENT_VERSION. Mirror of the claude-code install block.

Shim (infrastructure/container/container.ts) — ensureExecShim() reads ${CLAUDE_HOOKS_CONTAINER_BIN:-/usr/local/bin/claude} to pick the in-container CLI. New constant CONTAINER_CURSOR_EXECUTABLE. Test pins the contract: default fallback unchanged for claude; env override swaps the binary.

Env (domain/agent/agent-runner.ts) — CONTAINER_FORWARDED_ENV adds CURSOR_API_KEY. buildAgentEnv accepts provider; on "cursor" sets CLAUDE_HOOKS_CONTAINER_BIN. Tests pin both the cursor and claude paths. runAgentTask threads taskProvider through.

Adapter (infrastructure/agent/cursor-cli-adapter.ts) — picks binary in priority order: req.env.CURSOR_AGENT_BIN ?? req.pathToExecutable (the container shim) ?? cursor-agent (host fallback). Now the only cursor backend in agent-runner.ts; the in-process SdkCursorAgent is gone.

MCP per agent (infrastructure/agent-env-sync/render-for-instance.ts) — new renderCursorMcpJson writes <envDir>/cursor/mcp.json from the same DefaultMcpRegistry.serversFor source that builds claude's .claude.json::mcpServers. container-reconcile.ts adds the bind mount ${credsDir}/cursor:/home/claude/.cursor:ro. Same per-agent MCP registry powers both providers without a translation layer.

Drop the SDK — removed cursor-sdk-adapter.ts + its tests + the cursor-replay-conversation.json fixture + the apps/server/src/repro/ spike scripts. sdk-adapter.ts::fetchCursorModels shells out to cursor-agent models instead of Cursor.models.list. @cursor/sdk removed from apps/server/package.json (~16 MB out of node_modules).

Spec (docs/specs/cursor-in-docker.md) — design notes + checklist + rollout plan.

Why

The in-process @cursor/sdk 1.0.12 backend:

  • Crashes with NGHTTP2_FRAME_SIZE_ERROR whenever cwd contains .git/ (server-side bug in the GetTeamReposOrEmptyIfNotInTeam flow). PR #1027 worked around it via a symlink wrapper. The CLI sidesteps the codepath entirely.
  • Tools (Bash/Edit/Write) execute on the host as user charles with full filesystem access. Inconsistent with the claude path, where every tool runs inside the agent's container as claude:claude.
  • Per-agent git identity not enforced; commits land under the operator's name unless the model overrides --author.
  • No bind for ~/.cursor/mcp.json per-agent; MCP config leaks from the operator's host.

Containerising cursor-agent (which mirrors the claude-code CLI's -p --output-format stream-json contract) closes all four gaps with the same infra claude already uses.

Test plan

  • bun test apps/server/src/infrastructure/{agent,container,agent-env-sync} — 194/194 pass
  • bun test apps/server/src/domain/agent/agent-runner.test.ts — 83/83 pass (incl. new buildAgentEnv cases for the cursor + anthropic provider paths)
  • bun x tsc --noEmit -p apps/server clean
  • grep -r '@cursor/sdk' apps/server/src returns only doc comments — no live import
  • just containers-rebuild dev rebuilds image with cursor-agent baked in
  • Live kick on a provider: cursor dev agent — confirm cursor-agent runs inside the container, branch pushed under per-agent identity, PR opened via forge MCP
  • CI green on Forgejo Actions

Out of scope

  • Migrate the claude path off @anthropic-ai/claude-agent-sdk onto bare CLI spawn. The SDK still pulls weight (abort + stdin steer + typed SDKMessage); cleanup later if useful.
  • Map ~/.cursor/rules/*.mdc from claude's ~/.claude/skills/*.md. Not load-bearing for a functional cursor-in-docker dispatch; separate spec.
  • Auto-bump for CURSOR_AGENT_VERSION. Pinned via ARG for now; operator bumps manually like claude.

🤖 Generated with Claude Code

## Summary Brings the `cursor` provider to feature parity with the `anthropic` (claude) provider's containerised dispatch. Same image, same shim, same per-agent identity, same MCP wiring. Drops `@cursor/sdk` from the runtime tree. ``` agent-runner.ts (host) │ pathToExecutable = ~/.cache/claude-hooks/container-exec.sh │ env = { CLAUDE_HOOKS_CONTAINER, CLAUDE_HOOKS_DOCKER_CWD, │ CLAUDE_HOOKS_CONTAINER_BIN=cursor-agent, │ CLAUDE_HOOKS_DOCKER_ENV=CURSOR_API_KEY ... } ▼ CliCursorAgent.runTask() → Bun.spawn([shim, "-p", "--output-format", "stream-json", "--workspace", <inContainerCwd>, ...]) ▼ container-exec.sh → docker exec -i -w "$CWD" -e CURSOR_API_KEY ... \ claude-hooks-<agent> /usr/local/bin/cursor-agent ... ▼ container `claude-hooks-<agent>` └─ git identity = $GIT_AUTHOR_NAME / $GIT_COMMITTER_NAME (per-agent) MCP servers from /home/claude/.cursor/mcp.json cwd = /state/worktrees/<branch> ``` **One shim, two providers.** `CLAUDE_HOOKS_CONTAINER_BIN` env var picks which binary the shim execs (defaults to `claude` for back-compat). Cursor path sets it to `cursor-agent`. ## Changes **Image** (`Dockerfile`) — install `cursor-agent` next to `claude` from the official `downloads.cursor.com` tarball, pinned by `ARG CURSOR_AGENT_VERSION`. Mirror of the claude-code install block. **Shim** (`infrastructure/container/container.ts`) — `ensureExecShim()` reads `${CLAUDE_HOOKS_CONTAINER_BIN:-/usr/local/bin/claude}` to pick the in-container CLI. New constant `CONTAINER_CURSOR_EXECUTABLE`. Test pins the contract: default fallback unchanged for claude; env override swaps the binary. **Env** (`domain/agent/agent-runner.ts`) — `CONTAINER_FORWARDED_ENV` adds `CURSOR_API_KEY`. `buildAgentEnv` accepts `provider`; on `"cursor"` sets `CLAUDE_HOOKS_CONTAINER_BIN`. Tests pin both the cursor and claude paths. `runAgentTask` threads `taskProvider` through. **Adapter** (`infrastructure/agent/cursor-cli-adapter.ts`) — picks binary in priority order: `req.env.CURSOR_AGENT_BIN` ?? `req.pathToExecutable` (the container shim) ?? `cursor-agent` (host fallback). Now the only cursor backend in `agent-runner.ts`; the in-process `SdkCursorAgent` is gone. **MCP per agent** (`infrastructure/agent-env-sync/render-for-instance.ts`) — new `renderCursorMcpJson` writes `<envDir>/cursor/mcp.json` from the same `DefaultMcpRegistry.serversFor` source that builds claude's `.claude.json::mcpServers`. `container-reconcile.ts` adds the bind mount `${credsDir}/cursor:/home/claude/.cursor:ro`. Same per-agent MCP registry powers both providers without a translation layer. **Drop the SDK** — removed `cursor-sdk-adapter.ts` + its tests + the `cursor-replay-conversation.json` fixture + the `apps/server/src/repro/` spike scripts. `sdk-adapter.ts::fetchCursorModels` shells out to `cursor-agent models` instead of `Cursor.models.list`. `@cursor/sdk` removed from `apps/server/package.json` (~16 MB out of `node_modules`). **Spec** (`docs/specs/cursor-in-docker.md`) — design notes + checklist + rollout plan. ## Why The in-process `@cursor/sdk` 1.0.12 backend: - Crashes with `NGHTTP2_FRAME_SIZE_ERROR` whenever cwd contains `.git/` (server-side bug in the `GetTeamReposOrEmptyIfNotInTeam` flow). PR #1027 worked around it via a symlink wrapper. The CLI sidesteps the codepath entirely. - Tools (Bash/Edit/Write) execute on the **host** as user `charles` with full filesystem access. Inconsistent with the claude path, where every tool runs inside the agent's container as `claude:claude`. - Per-agent git identity not enforced; commits land under the operator's name unless the model overrides `--author`. - No bind for `~/.cursor/mcp.json` per-agent; MCP config leaks from the operator's host. Containerising `cursor-agent` (which mirrors the claude-code CLI's `-p --output-format stream-json` contract) closes all four gaps with the same infra claude already uses. ## Test plan - [x] `bun test apps/server/src/infrastructure/{agent,container,agent-env-sync}` — 194/194 pass - [x] `bun test apps/server/src/domain/agent/agent-runner.test.ts` — 83/83 pass (incl. new `buildAgentEnv` cases for the cursor + anthropic provider paths) - [x] `bun x tsc --noEmit -p apps/server` clean - [x] `grep -r '@cursor/sdk' apps/server/src` returns only doc comments — no live import - [ ] `just containers-rebuild dev` rebuilds image with `cursor-agent` baked in - [ ] Live kick on a `provider: cursor` dev agent — confirm `cursor-agent` runs inside the container, branch pushed under per-agent identity, PR opened via forge MCP - [ ] CI green on Forgejo Actions ## Out of scope - Migrate the claude path off `@anthropic-ai/claude-agent-sdk` onto bare CLI spawn. The SDK still pulls weight (abort + stdin steer + typed SDKMessage); cleanup later if useful. - Map `~/.cursor/rules/*.mdc` from claude's `~/.claude/skills/*.md`. Not load-bearing for a functional cursor-in-docker dispatch; separate spec. - Auto-bump for `CURSOR_AGENT_VERSION`. Pinned via `ARG` for now; operator bumps manually like claude. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(cursor): run cursor-agent inside docker like claude
Some checks failed
qa / sql-layer-check (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 17s
qa / db-schema (pull_request) Successful in 19s
qa / dockerfile (pull_request) Successful in 25s
qa / qa-1 (pull_request) Failing after 40s
qa / qa (pull_request) Failing after 0s
df632842d6
Brings the `cursor` provider to feature parity with the `anthropic`
(claude) provider's containerised dispatch. Same image, same shim,
same per-agent identity, same MCP wiring. Drops `@cursor/sdk` from the
runtime tree.

## Changes

**Image** (`Dockerfile`)
  - Install `cursor-agent` next to the existing `claude` CLI from the
    official `downloads.cursor.com` tarball, pinned by `ARG
    CURSOR_AGENT_VERSION`. Mirror of the claude-code install block.

**Shim** (`infrastructure/container/container.ts`)
  - `ensureExecShim()` now reads `${CLAUDE_HOOKS_CONTAINER_BIN}` to
    pick which CLI inside the container the shim execs, defaulting to
    the existing `/usr/local/bin/claude`. One shim, two providers.
  - New constant `CONTAINER_CURSOR_EXECUTABLE = /usr/local/bin/cursor-agent`.
  - Test pins the contract: default fallback is the claude path, env
    override swaps the binary.

**Env wiring** (`domain/agent/agent-runner.ts`)
  - `CONTAINER_FORWARDED_ENV` adds `CURSOR_API_KEY` so the host-injected
    key reaches the container.
  - `buildAgentEnv` accepts `provider`; when `"cursor"` it sets
    `CLAUDE_HOOKS_CONTAINER_BIN` to the cursor-agent path. Tests pin
    both the cursor and claude paths.
  - `runAgentTask` threads `taskProvider` into `buildAgentEnv`.

**Adapter** (`infrastructure/agent/cursor-cli-adapter.ts`)
  - Binary picked from `req.env.CURSOR_AGENT_BIN` ?? `req.pathToExecutable`
    ?? `cursor-agent`. Container mode threads the shim path through
    `pathToExecutable` (set by agent-runner). Host mode falls back to
    the system binary.
  - Adapter is now the only cursor backend in `agent-runner.ts`. The
    in-process `SdkCursorAgent` fallback is removed.

**MCP per agent** (`infrastructure/agent-env-sync/render-for-instance.ts`)
  - New `renderCursorMcpJson` writes `<envDir>/cursor/mcp.json` from
    the same `DefaultMcpRegistry.serversFor` source that builds claude's
    `.claude.json::mcpServers`. Schema mirrors cursor's documented
    `~/.cursor/mcp.json` shape, so the same per-agent MCP registry
    powers both providers without a translation layer.
  - `container-reconcile.ts` adds the bind mount
    `${credsDir}/cursor:/home/claude/.cursor:ro`.

**Drop the SDK**
  - Removed `apps/server/src/infrastructure/agent/cursor-sdk-adapter.ts`,
    its test file, the `cursor-sdk-delta.test.ts`, the
    `cursor-replay-conversation.json` fixture, and the `apps/server/src/repro/`
    spike scripts.
  - `sdk-adapter.ts::fetchCursorModels` shells out to
    `cursor-agent models` instead of `Cursor.models.list`.
  - `@cursor/sdk` removed from `apps/server/package.json`.

**Spec** (`docs/specs/cursor-in-docker.md`) — design notes + checklist.

## Test plan
- [x] `bun test apps/server/src/infrastructure/{agent,container,agent-env-sync}` clean
- [x] `bun test apps/server/src/domain/agent/agent-runner.test.ts` clean (incl. new buildAgentEnv cases)
- [x] `bun x tsc --noEmit -p apps/server` clean
- [ ] `just containers-rebuild dev` rebuilds image with `cursor-agent` baked in
- [ ] Live kick on `provider: cursor` dev agent — confirm `cursor-agent`
      runs inside container, branch pushed under per-agent identity, PR
      opened via forge MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
charles force-pushed feat/cursor-in-docker from df632842d6
Some checks failed
qa / sql-layer-check (pull_request) Successful in 12s
qa / i18n-string-check (pull_request) Successful in 17s
qa / db-schema (pull_request) Successful in 19s
qa / dockerfile (pull_request) Successful in 25s
qa / qa-1 (pull_request) Failing after 40s
qa / qa (pull_request) Failing after 0s
to 8245954cec
All checks were successful
qa / sql-layer-check (pull_request) Successful in 4s
qa / i18n-string-check (pull_request) Successful in 8s
qa / db-schema (pull_request) Successful in 9s
qa / dockerfile (pull_request) Successful in 10s
qa / qa-1 (pull_request) Successful in 2m36s
qa / qa (pull_request) Successful in 0s
2026-05-10 09:20:06 +00:00
Compare
claude-desktop left a comment

Subagent code review — flagged 3 substantive issues that should be resolved before merge.

1. --workspace arg points at host cwd, not in-container cwd

cursor-cli-adapter.ts:200 passes req.cwd to --workspace. In container mode, agent-runner.ts:977 sets req.cwd = process.cwd() (host service cwd via resolveHostCwd), so cursor-agent is told --workspace /home/charles/Workspace/claude-hooks — a path that does not exist inside the container. The shim's -w "$CLAUDE_HOOKS_DOCKER_CWD" fixes the process cwd to the in-container worktree, so cursor might fall back to its own cwd, but the explicit --workspace flag is at best confusing and at worst will make cursor-agent error or operate against the wrong tree.

docs/specs/cursor-in-docker.md:120 wrongly assumes req.cwd is already the in-container path.

Fix: thread inContainerCwd separately on RunTaskRequest, or have the cursor adapter prefer req.env.CLAUDE_HOOKS_DOCKER_CWD over req.cwd.

2. cursor-cli-adapter.ts (438 LoC) has zero unit tests

Deleted cursor-sdk-adapter.test.ts covered ~957 lines of mapping behaviour. Replacement adapter has identical event-mapping complexity (mapEvent, pickToolEntry, summarizeArgs, summarizeResult, shouldResume, normalizeUserTurns) and is now the only cursor backend. Needs equivalent coverage — esp. binary-selection priority order (req.env.CURSOR_AGENT_BIN > req.pathToExecutable > default), resume-sentinel blacklist, result-event ok/error mapping.

3. MCP bind path: spec contradicts impl

docs/specs/cursor-in-docker.md:132 says mount at /home/claude/.config/cursor/. Implementation at container-reconcile.ts:424 mounts at /home/claude/.cursor. Implementation matches official cursor docs (~/.cursor/mcp.json) so likely correct — update spec.

Confirmed clean

  • Shim default /usr/local/bin/claude preserved; both branches pinned in container.test.ts:279-289
  • Per-agent git identity flow: gitIdentityEnvagentEnv → shim → docker exec -e GIT_AUTHOR_NAME; no operator leak
  • Per-task CURSOR_API_KEY forwarded via shim's per-process env (agent-runner.ts:954), not host wholesale
  • MCP per agent: same serversFor source for .claude.json and cursor/mcp.json; ro bind
  • SDK fully removed (cursor-sdk-adapter.ts, cursor-sdk-delta.test.ts, cursor-replay-conversation.json, apps/server/src/repro/); @cursor/sdk gone from package.json + bun.lock
  • fetchCursorModels shells out to cursor-agent models with per-call CURSOR_API_KEY
  • Dockerfile: CURSOR_AGENT_VERSION ARG, mirrors claude-code install, hadolint clean
  • pipeline-stall.test.ts _resetConfigForTest() is the correct fix; same bleed risk exists pre-existing in other loadWebhookConfig callers (http/webhook.test.ts, analytics/agents-health.test.ts, forge/adapter-factory.test.ts) — separate cleanup

CI green, full suite 3405/3405.

Subagent code review — flagged 3 substantive issues that should be resolved before merge. ### 1. `--workspace` arg points at host cwd, not in-container cwd `cursor-cli-adapter.ts:200` passes `req.cwd` to `--workspace`. In container mode, `agent-runner.ts:977` sets `req.cwd = process.cwd()` (host service cwd via `resolveHostCwd`), so `cursor-agent` is told `--workspace /home/charles/Workspace/claude-hooks` — a path that does not exist inside the container. The shim's `-w "$CLAUDE_HOOKS_DOCKER_CWD"` fixes the process cwd to the in-container worktree, so cursor *might* fall back to its own cwd, but the explicit `--workspace` flag is at best confusing and at worst will make cursor-agent error or operate against the wrong tree. `docs/specs/cursor-in-docker.md:120` wrongly assumes `req.cwd` is already the in-container path. **Fix:** thread `inContainerCwd` separately on `RunTaskRequest`, or have the cursor adapter prefer `req.env.CLAUDE_HOOKS_DOCKER_CWD` over `req.cwd`. ### 2. `cursor-cli-adapter.ts` (438 LoC) has zero unit tests Deleted `cursor-sdk-adapter.test.ts` covered ~957 lines of mapping behaviour. Replacement adapter has identical event-mapping complexity (`mapEvent`, `pickToolEntry`, `summarizeArgs`, `summarizeResult`, `shouldResume`, `normalizeUserTurns`) and is now the only cursor backend. Needs equivalent coverage — esp. binary-selection priority order (`req.env.CURSOR_AGENT_BIN` > `req.pathToExecutable` > default), resume-sentinel blacklist, result-event ok/error mapping. ### 3. MCP bind path: spec contradicts impl `docs/specs/cursor-in-docker.md:132` says mount at `/home/claude/.config/cursor/`. Implementation at `container-reconcile.ts:424` mounts at `/home/claude/.cursor`. Implementation matches official cursor docs (`~/.cursor/mcp.json`) so likely correct — update spec. ### Confirmed clean - Shim default `/usr/local/bin/claude` preserved; both branches pinned in `container.test.ts:279-289` - Per-agent git identity flow: `gitIdentityEnv` → `agentEnv` → shim → `docker exec -e GIT_AUTHOR_NAME`; no operator leak - Per-task `CURSOR_API_KEY` forwarded via shim's per-process env (`agent-runner.ts:954`), not host wholesale - `MCP per agent`: same `serversFor` source for `.claude.json` and `cursor/mcp.json`; ro bind - SDK fully removed (`cursor-sdk-adapter.ts`, `cursor-sdk-delta.test.ts`, `cursor-replay-conversation.json`, `apps/server/src/repro/`); `@cursor/sdk` gone from `package.json` + `bun.lock` - `fetchCursorModels` shells out to `cursor-agent models` with per-call `CURSOR_API_KEY` - Dockerfile: `CURSOR_AGENT_VERSION` ARG, mirrors claude-code install, hadolint clean - `pipeline-stall.test.ts` `_resetConfigForTest()` is the correct fix; same bleed risk exists pre-existing in other `loadWebhookConfig` callers (`http/webhook.test.ts`, `analytics/agents-health.test.ts`, `forge/adapter-factory.test.ts`) — separate cleanup CI green, full suite 3405/3405.
charles force-pushed feat/cursor-in-docker from 8245954cec
All checks were successful
qa / sql-layer-check (pull_request) Successful in 4s
qa / i18n-string-check (pull_request) Successful in 8s
qa / db-schema (pull_request) Successful in 9s
qa / dockerfile (pull_request) Successful in 10s
qa / qa-1 (pull_request) Successful in 2m36s
qa / qa (pull_request) Successful in 0s
to bf7e6bb57a
All checks were successful
qa / dockerfile (pull_request) Successful in 5s
qa / sql-layer-check (pull_request) Successful in 7s
qa / db-schema (pull_request) Successful in 8s
qa / i18n-string-check (pull_request) Successful in 10s
qa / qa-1 (pull_request) Successful in 2m9s
qa / qa (pull_request) Successful in 0s
2026-05-10 10:26:58 +00:00
Compare
charles deleted branch feat/cursor-in-docker 2026-05-10 10:54:05 +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/claude-hooks!1032
No description provided.