diff --git a/backend/Dockerfile.worker b/backend/Dockerfile.worker index a8a278287..ea0a13634 100644 --- a/backend/Dockerfile.worker +++ b/backend/Dockerfile.worker @@ -38,9 +38,14 @@ RUN dotnet restore CodeSpace.sln RUN dotnet publish src/CodeSpace.Api/CodeSpace.Api.csproj -c Release -o /app --no-restore # ── 2. The Node-based harness CLIs, version-pinned (override at build time with --build-arg) ── +# These two ARG defaults are the SINGLE SOURCE OF TRUTH for the pinned CLI versions. A test +# (HarnessVersionPinTests) asserts CodexHarness.DefaultVersion / ClaudeCodeHarness.DefaultVersion match these exact +# strings, so the harness-reported version can NEVER silently drift from what the worker actually installs — bump +# here and the test fails until the C# constants follow. `deploy/sync-local-harnesses.sh` installs these same pins +# locally, so a dev box matches the worker. Keep all three (this file · the harness consts · a local install) in lockstep. FROM node:20-bookworm-slim AS agent-cli ARG CODEX_CLI_VERSION=0.142.2 -ARG CLAUDE_CODE_VERSION=2.1.191 +ARG CLAUDE_CODE_VERSION=2.1.193 RUN npm install -g "@openai/codex@${CODEX_CLI_VERSION}" "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # ── 3. Runtime: agent-execution + isolation deps + the Node runtime & CLIs + the published app ── diff --git a/backend/deploy/sync-local-harnesses.sh b/backend/deploy/sync-local-harnesses.sh new file mode 100755 index 000000000..128e8ef41 --- /dev/null +++ b/backend/deploy/sync-local-harnesses.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Sync the LOCAL agent-CLI harnesses to the EXACT versions the worker image pins — so a dev box runs the same codex +# /claude the worker does, and the harness-reported version (CodexHarness/ClaudeCodeHarness.DefaultVersion, pinned to +# these same ARGs by HarnessVersionPinTests) stays honest. The single source of truth is backend/Dockerfile.worker. +# +# backend/deploy/sync-local-harnesses.sh +# +# After a version bump, the lockstep is: edit the Dockerfile ARG → update the matching DefaultVersion C# const (the +# pin test enforces this) → run this script on every dev box. Nothing to remember beyond "bump the ARG, run this". +set -euo pipefail + +DOCKERFILE="$(cd "$(dirname "$0")/.." && pwd)/Dockerfile.worker" +[ -f "$DOCKERFILE" ] || { echo "✗ $DOCKERFILE not found"; exit 1; } + +codex_v=$(grep -oE 'ARG CODEX_CLI_VERSION=[^[:space:]]+' "$DOCKERFILE" | head -1 | cut -d= -f2) +claude_v=$(grep -oE 'ARG CLAUDE_CODE_VERSION=[^[:space:]]+' "$DOCKERFILE" | head -1 | cut -d= -f2) +echo "Worker-pinned versions: codex=$codex_v claude=$claude_v" + +npm install -g "@openai/codex@${codex_v}" "@anthropic-ai/claude-code@${claude_v}" + +# Verify the EFFECTIVE (PATH) binaries match — a native claude/codex install can shadow the npm one. +check() { # $1=binary $2=expected + local got; got=$("$1" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "missing") + if [ "$got" = "$2" ]; then echo "✓ $1 $got"; else + echo "⚠ $1 on PATH is $got, expected $2 — a native install may be shadowing npm. Update it (e.g. 'claude update') or fix PATH order." + fi +} +check codex "$codex_v" +check claude "$claude_v" diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs index abedd2149..bba7f1565 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs @@ -76,7 +76,8 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IModelCredentialProjector private const string AnthropicProvider = "Anthropic"; - private const string DefaultVersion = "2.1.0"; + /// The pinned Claude Code CLI version — MUST match CLAUDE_CODE_VERSION in backend/Dockerfile.worker (the single source of truth); a pin test fails if they drift. + internal const string DefaultVersion = "2.1.193"; private const string DefaultCommand = "claude"; diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs index 0fa8bd00f..6fcd2f7eb 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs @@ -49,7 +49,8 @@ public sealed class CodexHarness : IAgentHarness, IModelCredentialProjector, IMc /// public const string ConfigHomeEnvVar = "CODEX_HOME"; - private const string DefaultVersion = "0.2.0"; + /// The pinned Codex CLI version — MUST match CODEX_CLI_VERSION in backend/Dockerfile.worker (the single source of truth); a pin test fails if they drift. + internal const string DefaultVersion = "0.142.2"; private const string DefaultCommand = "codex"; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs index 47d00dc30..f0373798e 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs @@ -304,7 +304,7 @@ public void Version_uses_the_default_then_the_env_override() try { System.Environment.SetEnvironmentVariable(CodexHarness.VersionEnvVar, null); - new CodexHarness().Version.ShouldBe("0.2.0"); + new CodexHarness().Version.ShouldBe(CodexHarness.DefaultVersion); // tracks the Dockerfile pin (HarnessVersionPinTests) System.Environment.SetEnvironmentVariable(CodexHarness.VersionEnvVar, "9.9.9"); new CodexHarness().Version.ShouldBe("9.9.9"); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs new file mode 100644 index 000000000..1c98be67e --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; +using CodeSpace.Core.Services.Agents.Harnesses.Codex; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// Pins each harness's DefaultVersion constant to the SINGLE SOURCE OF TRUTH — the +/// CODEX_CLI_VERSION / CLAUDE_CODE_VERSION ARG in backend/Dockerfile.worker (the version the +/// worker image actually installs). A bump in the Dockerfile that isn't mirrored into the C# constant (or vice +/// versa) FAILS here, so the harness-reported version can never silently drift from what the worker runs. The third +/// surface — a developer's local install — is synced from the same ARG by deploy/sync-local-harnesses.sh. +/// +[Trait("Category", "Unit")] +public class HarnessVersionPinTests +{ + [Fact] + public void Codex_default_version_matches_the_worker_dockerfile_pin() => + DockerfileArg("CODEX_CLI_VERSION").ShouldBe(CodexHarness.DefaultVersion); + + [Fact] + public void Claude_default_version_matches_the_worker_dockerfile_pin() => + DockerfileArg("CLAUDE_CODE_VERSION").ShouldBe(ClaudeCodeHarness.DefaultVersion); + + private static string DockerfileArg(string name) + { + var content = File.ReadAllText(LocateWorkerDockerfile()); + var match = Regex.Match(content, $@"ARG\s+{Regex.Escape(name)}=(\S+)"); + + match.Success.ShouldBeTrue($"ARG {name} not found in backend/Dockerfile.worker"); + return match.Groups[1].Value; + } + + private static string LocateWorkerDockerfile() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "backend", "Dockerfile.worker"); + if (File.Exists(candidate)) return candidate; + } + + throw new FileNotFoundException("backend/Dockerfile.worker not found walking up from " + AppContext.BaseDirectory); + } +}