From a53e061d160c0ae18125106bc3e188819c5d950e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 25 Sep 2026 09:29:22 +0200 Subject: [PATCH 1/3] The eval image ships python3 and uv for plugin MCP servers Plugin MCP servers failed to start in eval sessions: the image had only git and curl, so sphinx-mcp.sh fell back from uv to python3 and died with "python3: not found" (exit 127). Claude registered none of the plugin's tools. Bookworm's python3 is 3.11, which meets sphinx's requirement (>= 3.11). uv comes from its installer, into ~/.local/bin, already on PATH. Verified: rebuilt with ./gradlew claudeEvalImage. Started the sphinx server in the image with ~/.claude mounted read-only: 82 tools listed. This needs the sphinx launcher fix (venv parent dir) to start unaided. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src/main/docker/claude-eval/Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/docker/claude-eval/Dockerfile b/app/src/main/docker/claude-eval/Dockerfile index b586dd9d..ebaf1cc9 100644 --- a/app/src/main/docker/claude-eval/Dockerfile +++ b/app/src/main/docker/claude-eval/Dockerfile @@ -13,10 +13,16 @@ 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. +# python3: plugin MCP servers and hooks run Python (e.g. sphinx's server, +# which needs >= 3.11; bookworm ships 3.11). RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl git \ + ca-certificates curl git python3 \ && rm -rf /var/lib/apt/lists/* +# uv: plugin launchers (e.g. sphinx-mcp.sh) use `uv run` to install their +# pinned Python deps on first start. Installs to ~/.local/bin (on PATH below). +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + # 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. From a3aea0ef75342b1ce8c2440cb6eb44d01c711e7c Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 25 Sep 2026 09:29:23 +0200 Subject: [PATCH 2/3] An ended eval session removes its container Closing an eval session killed only the docker client. The container kept running, and unmark() deleted its config dir under it. Six such containers had built up here, some over a day old. Containers are now named drydock-eval-. unmark() runs `docker rm -f` on that name before it deletes the config dir. mark() does the same before launch: a container left over from a crash would otherwise block `docker run --name` on the next resume. Verified: ClaudeEvalContainerTest checks the --name; the Claude provider tests pass. Not tried in a live drydock session. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../claude/internal/ClaudeEvalContainer.java | 39 ++++++++++++++++++- .../internal/ClaudeEvalContainerTest.java | 2 + 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java b/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java index 30cab8f3..bcd5b9b0 100644 --- a/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java +++ b/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java @@ -10,6 +10,7 @@ import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.PosixFilePermissions; import java.nio.file.attribute.PosixFilePermission; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; @@ -27,6 +28,7 @@ import app.drydock.agent.api.EvalTokenResolver; import app.drydock.process.ProcessResult; import app.drydock.process.ProcessRunner; +import app.drydock.process.ProcessTimeoutException; import app.drydock.state.json.JsonParseException; import app.drydock.state.json.JsonParser; import app.drydock.state.json.JsonValue; @@ -287,6 +289,9 @@ public Optional mark(String sessionKey) { if (sessionKey == null || sessionKey.isBlank()) { return Optional.empty(); } + // A container left behind by a crash (no unmark) would make this + // launch's `docker run --name` fail on the name conflict. + removeContainer(sessionKey); Path configDir = stateDirectory.resolve("eval").resolve(sessionKey); try { Files.createDirectories(configDir); @@ -306,12 +311,16 @@ public Optional mark(String sessionKey) { return Optional.of(setup); } - /** Reverses {@link #mark}: deletes the per-session config dir and drops the stash. Idempotent. */ + /** + * Reverses {@link #mark}: removes the session's container, deletes the + * per-session config dir and drops the stash. Idempotent. + */ public void unmark(String sessionKey) { if (sessionKey == null || sessionKey.isBlank()) { return; } setups.remove(sessionKey); + removeContainer(sessionKey); Path configDir = stateDirectory.resolve("eval").resolve(sessionKey); try { deleteRecursively(configDir); @@ -320,6 +329,31 @@ public void unmark(String sessionKey) { } } + /** The {@code docker run --name} of {@code sessionKey}'s container. */ + static String containerName(String sessionKey) { + return "drydock-eval-" + sessionKey; + } + + /** + * Force-removes {@code sessionKey}'s container, if one exists. Closing a + * session's terminal kills only the {@code docker} client; without this + * the container keeps running with its mounts indefinitely. + */ + private static void removeContainer(String sessionKey) { + String name = containerName(sessionKey); + try { + ProcessResult res = ProcessRunner.run(List.of("docker", "rm", "-f", name), null, Duration.ofSeconds(15)); + if (res.exitCode() != 0 && !res.stderr().contains("No such container")) { + LOG.log(Level.WARNING, () -> "Could not remove eval container " + name + " (exit " + + res.exitCode() + "): " + ProcessRunner.excerpt(res.stderr())); + } + } catch (IOException | ProcessTimeoutException e) { + LOG.log(Level.WARNING, () -> "Could not remove eval container " + name + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + /** The stashed setup for {@code sessionKey}, or empty if {@link #mark} has not run (or failed). */ public Optional setupFor(String sessionKey) { return Optional.ofNullable(setups.get(sessionKey)); @@ -373,6 +407,9 @@ public String wrap(EvalSetup setup, String innerCommand, Path worktree, Optional Path mainRepoRoot = resolveMainRepoRoot(worktree); StringBuilder cmd = new StringBuilder("docker run --rm -it"); + // Named after the session key (mark() names the config dir after it) + // so unmark() can remove the container. + cmd.append(" --name ").append(shellQuote(containerName(setup.configDir().getFileName().toString()))); cmd.append(" --add-host=host.docker.internal:host-gateway"); cmd.append(" -e ").append(CONFIG_DIR_ENV).append('=').append(shellQuote(setup.configDir().toString())); cmd.append(" -v ").append(shellQuote(setup.configDir().toString())) diff --git a/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java b/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java index 177a4752..08d04cca 100644 --- a/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java +++ b/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java @@ -179,6 +179,8 @@ void wrapWritesTokenToFileNotArgvAndDropsDoubleSh() throws Exception { String imageAndAfter = cmd.substring(cmd.indexOf("drydock-claude-eval:latest")); assertFalse(imageAndAfter.contains(" sh "), "no extra 'sh' after the image (entrypoint is already sh)"); assertTrue(imageAndAfter.startsWith("drydock-claude-eval:latest '")); + // Named after the session key, so unmark() can remove the container. + assertTrue(cmd.contains(" --name 'drydock-eval-sess' "), () -> "container named after the key: " + cmd); } @Test From e3e96cb847e95bf6ac7f1bc17cbbbee0bd66b12e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 25 Sep 2026 09:29:25 +0200 Subject: [PATCH 3/3] Eval containers share a data volume, so plugin installs persist Every eval container starts fresh. sphinx's launcher builds its uv venv under XDG_DATA_HOME on first start, which took 60-100 s, so each eval session paid that before its MCP tools appeared. All eval containers now mount one named volume, drydock-claude-eval-data, as XDG_DATA_HOME. Only the first session builds the venv. Claude Code in the image does not depend on XDG_DATA_HOME: its launcher symlink is absolute, and it wrote nothing to the volume. Verified: fixed sphinx launcher, real volume: cold start 101 s, warm start in a new container 2 s, 82 tools both times. The Claude provider tests pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../claude/internal/ClaudeEvalContainer.java | 15 ++++++++++++++- .../claude/internal/ClaudeEvalContainerTest.java | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java b/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java index bcd5b9b0..274140f5 100644 --- a/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java +++ b/app/src/main/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainer.java @@ -102,7 +102,9 @@ * {@code --settings}/{@code --mcp-config} flags resolve unchanged and the * host-side activity watcher keeps reading the same files. The personal * config mounts are read-only (see above); everything else is writable - * because the session owns its state there.

+ * because the session owns its state there. One named volume is shared by + * all eval containers as {@code XDG_DATA_HOME} (see {@link #DATA_VOLUME}), + * so plugin installs made on first start outlive the container.

* *

All methods are blocking and must be called off the JavaFX application * thread. {@link #probe} is run once at provider init (background) and its @@ -116,6 +118,15 @@ public class ClaudeEvalContainer { private static final String IMAGE = System.getProperty( "app.drydock.eval.claude.image", "drydock-claude-eval:latest"); + /** + * Named volume shared by every eval container and mounted as + * {@code XDG_DATA_HOME}, so what plugins install there on first start + * (e.g. sphinx's uv venv, about a minute to build) persists across + * sessions instead of being rebuilt in each fresh container. + */ + private static final String DATA_VOLUME = "drydock-claude-eval-data"; + private static final String DATA_VOLUME_PATH = "/var/lib/drydock-eval-data"; + /** * Managed-settings locations, in precedence order (macOS first, then Linux). * Resolved per call (not cached at class-load) so a test can point the @@ -424,6 +435,8 @@ public String wrap(EvalSetup setup, String innerCommand, Path worktree, Optional .append(':').append(shellQuote(hooksDir.toString())); cmd.append(" -v ").append(shellQuote(activityDir.toString())) .append(':').append(shellQuote(activityDir.toString())); + cmd.append(" -v ").append(DATA_VOLUME).append(':').append(DATA_VOLUME_PATH); + cmd.append(" -e XDG_DATA_HOME=").append(DATA_VOLUME_PATH); // The config-dir symlinks (mirrorPersonalConfig) resolve against the // user's ~/.claude, so it must exist in the container at its original // host path -- read-only, so a container session can never mutate the diff --git a/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java b/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java index 08d04cca..a93e4ce6 100644 --- a/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java +++ b/app/src/test/java/app/drydock/agent/providers/claude/internal/ClaudeEvalContainerTest.java @@ -181,6 +181,9 @@ void wrapWritesTokenToFileNotArgvAndDropsDoubleSh() throws Exception { assertTrue(imageAndAfter.startsWith("drydock-claude-eval:latest '")); // Named after the session key, so unmark() can remove the container. assertTrue(cmd.contains(" --name 'drydock-eval-sess' "), () -> "container named after the key: " + cmd); + // The shared data volume backs XDG_DATA_HOME, so plugin installs persist. + assertTrue(cmd.contains(" -v drydock-claude-eval-data:/var/lib/drydock-eval-data " + + "-e XDG_DATA_HOME=/var/lib/drydock-eval-data "), () -> "shared data volume: " + cmd); } @Test