Claude eval sessions run in a container seeded from managed settings - #22
Merged
Conversation
Managed settings (managed-settings.json) are the highest-precedence scope and cannot be overridden by env vars or --settings, so drydock could not add an x-target-account: eval header to them. The previous approach marked the session id with the host's omlx_proxy, which injected the header per request -- but it depended on omlx_proxy running and on session-id correlation, and it left the header source outside drydock's control. An eval session now runs inside a container (docker on Linux, colima on macOS) where there is no managed-settings file, so a settings file drydock seeds in CLAUDE_CONFIG_DIR is the only source of truth. The seed is copied from the host's managed settings with three edits: ANTHROPIC_BASE_URL is rewritten to host.docker.internal so the container still reaches the host's omlx_proxy, x-target-account: eval is appended to ANTHROPIC_CUSTOM_HEADERS, and apiKeyHelper is dropped (the token is resolved on the host and passed in as ANTHROPIC_API_KEY). The auth token is written to an owner-only file in the mounted config dir and the entrypoint exports it from there -- it never appears in `ps` or the command preview the UI shows. A git worktree's .git is a file pointing back into the main repo's object store via an absolute path, so for git to work inside the container both the worktree and the main repo root are bind-mounted at their original host paths. The activity-hook script, the activity state directory, and the per-session MCP config file are mounted at their host paths too, so the existing --settings/--mcp-config flags resolve unchanged and the host-side activity watcher keeps reading the same files. The auth token is a JWT; its exp claim is decoded (no signature verification -- display only) so the UI shows a countdown in the session tooltip. The token's real TTL is 6h (not the 2h CLAUDE_CODE_API_KEY_HELPER_TTL_MS, which is claude's own re-invocation cache for the helper script). The ddtool command that resolves the token is behind an EvalTokenResolver SPI; the only implementation is DtoolEvalTokenResolver (DataDog-specific). Once drydock grows plugin support it is intended to move behind a DD plugin so the eval integration is not bound to a single credential source. The image is built on demand: `./gradlew claudeEvalImage`. evalAvailable() probes both that the daemon answers and that the image is present, so the UI checkbox is honestly gated. Non-eval sessions are unchanged: they keep launching claude directly on the host. Verified end-to-end: built the image, generated the docker run command, ran it under a real PTY -- the entrypoint executed inside the container and the token was read from the file, not the argv. The ClaudeEvalProxy (omlx marking) is deleted; nothing references it. Co-Authored-By: Claude <noreply@anthropic.com>
MANAGED_SETTINGS_PATHS was a static final field initialized once at class-load time, so the app.drydock.eval.claude.managedSettings system property the seedSettings test sets was read too late -- the field had already captured the default paths. The test passed on the dev machine because /Library/Application Support/ClaudeCode/managed-settings.json exists there, so readManagedSettings read the host's real managed settings instead of the test fixture. On CI (no such file) the seed was empty and the "source: claude-code" / "sonnet" assertions failed. Drop the static field and resolve the paths inside readManagedSettings each call, so a test (or a future runtime override) can redirect them after the class is loaded. Co-Authored-By: Claude <noreply@anthropic.com>
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.
What was wrong
Claude Code's managed settings (
managed-settings.json) are the highest-precedence scope and cannot be overridden by env vars or--settings. They pinANTHROPIC_BASE_URLat the host omlx_proxy and carryANTHROPIC_CUSTOM_HEADERS, so drydock could not add anx-target-account: evalheader to route an eval session's traffic to the eval account. The previous approach marked the session id out-of-band with the host's omlx_proxy, which injected the header per request — but it depended on omlx_proxy running, on session-id correlation, and left the header source outside drydock's control.What happens now
An eval session runs inside a container (docker on Linux, colima on macOS) where there is no managed-settings file, so a settings file drydock seeds in
CLAUDE_CONFIG_DIRis the only source of truth. The seed is copied from the host's managed settings with three edits:ANTHROPIC_BASE_URLis rewritten tohost.docker.internalso the container still reaches the host's omlx_proxy,x-target-account: evalis appended toANTHROPIC_CUSTOM_HEADERS, andapiKeyHelperis dropped (the token is resolved on the host and passed in asANTHROPIC_API_KEY). Non-eval sessions are unchanged: they keep launching claude directly on the host.The auth token is written to an owner-only file in the mounted config dir and the entrypoint exports it from there — it never appears in
psor the command preview the UI shows. The token is a JWT; itsexpclaim is decoded (no signature verification — display only) so the UI shows a countdown in the session tooltip. The token's real TTL is 6h (not the 2hCLAUDE_CODE_API_KEY_HELPER_TTL_MS, which is claude's own re-invocation cache for the helper script).Mounts
A git worktree's
.gitis a file pointing back into the main repo's object store via an absolute path, so for git to work inside the container both the worktree and the main repo root are bind-mounted at their original host paths. The activity-hook script, the activity state directory, and the per-session MCP config file are mounted at their host paths too, so the existing--settings/--mcp-configflags resolve unchanged and the host-side activity watcher keeps reading the same files. When the main repo root cannot be resolved (an unusual.gitlayout), only the worktree is mounted and aWARNINGis logged so a user whose git ops break inside eval has a breadcrumb.Token resolution SPI
The
ddtoolcommand that resolves the token is behind anEvalTokenResolverSPI; the only implementation isDtoolEvalTokenResolver(DataDog-specific). Once drydock grows plugin support it is intended to move behind a DD plugin so the eval integration is not bound to a single credential source.Image
The image is built on demand:
./gradlew claudeEvalImage.evalAvailable()probes both that the daemon answers and that the image is present, so the UI checkbox is honestly gated. The image'sENTRYPOINTissh, so the generateddocker runcommand passes the entrypoint script path directly — no extrash, or it would besh sh <script>and fail.Verification
Built the image (
./gradlew claudeEvalImage), generated thedocker runcommand fromClaudeEvalContainer.wrap, and ran it under a real PTY (simulating ghostty's launch path): the entrypoint executed inside the container, the token was read from the file (not the argv), and interactive TTY detection succeeded. Unit tests cover the pure logic: JWTexpdecode, worktree.gitresolution, managed-settings seed edits (base URL rewrite, header append idempotency,apiKeyHelper/TTL drop), and thewrapoutput (no token in argv, no doublesh). The pre-existingSessionForkServiceTesttimeouts are unrelated to this change (reproduced on cleanmain).Housekeeping
The
ClaudeEvalProxy(omlx marking) is deleted; nothing references it. Run./gradlew claudeEvalImageonce on each host that wants eval mode — the image is not built by the regular build.