From d36c032d0cd7f14052ddf2a72e7eb822b5429751 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 26 Aug 2026 13:09:24 +0200 Subject: [PATCH 1/2] Claude eval runs in a container seeded from managed settings 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 --- app/build.gradle.kts | 12 + app/src/main/docker/claude-eval/Dockerfile | 32 ++ .../app/drydock/agent/api/AgentRegistry.java | 10 + .../drydock/agent/api/EvalTokenResolver.java | 38 ++ .../providers/claude/ClaudeAgentProvider.java | 84 +++- .../claude/internal/ClaudeEvalContainer.java | 435 ++++++++++++++++++ .../claude/internal/ClaudeEvalProxy.java | 144 ------ .../internal/DtoolEvalTokenResolver.java | 85 ++++ .../app/drydock/agent/spi/AgentProvider.java | 13 + .../app/drydock/ui/RepositorySidebar.java | 22 +- .../main/java/app/drydock/ui/UiFormats.java | 20 + .../claude/ClaudeAgentProviderTest.java | 43 ++ .../internal/ClaudeEvalContainerTest.java | 168 +++++++ .../internal/DtoolEvalTokenResolverTest.java | 38 ++ 14 files changed, 983 insertions(+), 161 deletions(-) create mode 100644 app/src/main/docker/claude-eval/Dockerfile create mode 100644 app/src/main/java/app/drydock/agent/api/EvalTokenResolver.java create mode 100644 app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java delete mode 100644 app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalProxy.java create mode 100644 app/src/main/java/app/drydock/agent/providers/claude/internal/DtoolEvalTokenResolver.java create mode 100644 app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java create mode 100644 app/src/test/java/app/drydock/agent/providers/claude/internal/DtoolEvalTokenResolverTest.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e8a49ed..67f78f00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -372,6 +372,18 @@ tasks.named("javadoc") { (options as StandardJavadocDocletOptions).addStringOption("Xdoclint:none", "-quiet") } +// The container image used for Claude eval sessions (see +// ClaudeEvalContainer). Built on demand -- `./gradlew claudeEvalImage` -- +// against whatever `docker` context is active (Colima on macOS, a local or +// remote daemon on Linux). Not wired into the regular build: it is a host +// setup step, not a per-checkout artifact, and it needs a running daemon. +tasks.register("claudeEvalImage") { + group = "build" + description = "Builds the drydock-claude-eval Docker image for Claude eval sessions." + workingDir = file("src/main/docker/claude-eval") + commandLine("docker", "build", "-t", "drydock-claude-eval:latest", ".") +} + // Central Portal Publisher API transport + signing for the custom `drydock` // publication created above. Credentials come from Gradle properties / env // (ORG_GRADLE_PROJECT_mavenCentralUsername/Password) and the in-memory GPG key diff --git a/app/src/main/docker/claude-eval/Dockerfile b/app/src/main/docker/claude-eval/Dockerfile new file mode 100644 index 00000000..b586dd9d --- /dev/null +++ b/app/src/main/docker/claude-eval/Dockerfile @@ -0,0 +1,32 @@ +# The container image used for Claude eval sessions (see +# ClaudeEvalContainer). Runs Claude Code's native Linux build inside a +# container so a seeded local settings file can carry the eval-specific +# x-target-account header, which the host's managed settings (locked to +# the highest precedence scope) forbid us from adding. +# +# Build on demand: ./gradlew claudeEvalImage +# (uses whatever `docker` context is active -- Colima on macOS, a local or +# remote daemon on Linux). + +FROM debian:bookworm-slim + +# git: Claude's own Bash tool calls `git`; the worktree's .git points back +# into the main repo object store, both mounted from the host. +# curl + ca-certificates: the native installer downloads over HTTPS. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git \ + && rm -rf /var/lib/apt/lists/* + +# Claude Code native build. The install script places the launcher at +# ~/.local/bin/claude and the versioned binary under ~/.local/share/claude. +# Runs as root in this image, so HOME=/root. +RUN curl -fsSL https://claude.ai/install.sh | bash + +ENV PATH="/root/.local/bin:${PATH}" + +# The inner `claude ...` command arrives as an entrypoint script mounted from +# the host (ClaudeEvalContainer.wrap writes it into the config-dir mount). +# This image's ENTRYPOINT is `sh`, so `docker run