diff --git a/.github/workflows/real-model.yml b/.github/workflows/real-model.yml index 712df50aa..62156e964 100644 --- a/.github/workflows/real-model.yml +++ b/.github/workflows/real-model.yml @@ -1058,12 +1058,12 @@ jobs: # BOTH harness binaries: claude (ClaudeCodeHarness) drives the blessed behavioral gate; codex (CodexHarness) # drives the informational behavioral proof + the deterministic real-binary proofs. FATAL install (a missing # binary must RED the lane, never let a gating arm self-skip green — "skip ≠ pass"). Pins match the worker image - # ARGs + the harness DefaultVersion consts the CLI surface is verified against (claude 2.1.193 / codex 0.142.2), + # ARGs + the harness DefaultVersion consts the CLI surface is verified against (claude 2.1.263 / codex 0.142.2), # so a future CLI flag change is a visible lane break to fix, not a silent harness regression. run: | # One retry each to absorb a transient npm-registry blip (ECONNRESET / 5xx) without false-red-ing the lane; a # genuinely missing/yanked binary still fails after the retry → reds (the install is fatal, not flaky). - npm install -g '@anthropic-ai/claude-code@2.1.193' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.193'; } + npm install -g '@anthropic-ai/claude-code@2.1.263' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.263'; } npm install -g '@openai/codex@0.142.2' || { sleep 5; npm install -g '@openai/codex@0.142.2'; } claude --version codex --version @@ -1206,7 +1206,7 @@ jobs: - name: Install the real coding-agent CLI (Claude Code) # FATAL install (a missing binary must RED the lane, never let the gating arm self-skip green — "skip ≠ pass"). run: | - npm install -g '@anthropic-ai/claude-code@2.1.193' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.193'; } + npm install -g '@anthropic-ai/claude-code@2.1.263' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.263'; } claude --version git --version @@ -1333,7 +1333,7 @@ jobs: - name: Install the real coding-agent CLI (Claude Code) # FATAL install (a missing binary must RED the lane, never let the gating agent-feed arm self-skip green — "skip ≠ pass"). run: | - npm install -g '@anthropic-ai/claude-code@2.1.193' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.193'; } + npm install -g '@anthropic-ai/claude-code@2.1.263' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.263'; } claude --version git --version @@ -1460,7 +1460,7 @@ jobs: # drives the hooks.json Stop hook. FATAL install (a missing binary must RED the lane, never let it self-skip # green — "skip ≠ pass"). Pins match the injection lane's own pinned versions. run: | - npm install -g '@anthropic-ai/claude-code@2.1.193' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.193'; } + npm install -g '@anthropic-ai/claude-code@2.1.263' || { sleep 5; npm install -g '@anthropic-ai/claude-code@2.1.263'; } npm install -g '@openai/codex@0.142.2' || { sleep 5; npm install -g '@openai/codex@0.142.2'; } claude --version codex --version diff --git a/backend/Dockerfile.worker b/backend/Dockerfile.worker index 74616eee8..b4667e53a 100644 --- a/backend/Dockerfile.worker +++ b/backend/Dockerfile.worker @@ -64,9 +64,14 @@ RUN SHA="${SOURCE_REVISION_ID:-$(git rev-parse HEAD 2>/dev/null || echo unknown) # 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 +# +# The Node major must satisfy the STRICTEST `engines.node` of the two pinned CLIs: claude-code 2.1.263 declares +# >=22 (2.1.193 declared >=18), codex 0.142.2 declares >=16. On node:20 npm still installs claude-code, but only +# because EBADENGINE is a WARNING — an `engine-strict` npm, or a future release that actually uses node-22 syntax +# in its install script, turns that warning into a failed image build. Track the floor rather than the warning. +FROM node:22-bookworm-slim AS agent-cli ARG CODEX_CLI_VERSION=0.142.2 -ARG CLAUDE_CODE_VERSION=2.1.193 +ARG CLAUDE_CODE_VERSION=2.1.263 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/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs index fcadb4350..b79a8b47a 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeCodeHarness.cs @@ -105,7 +105,7 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IAgentHarnessContractGene private const string AnthropicProvider = "Anthropic"; /// 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"; + internal const string DefaultVersion = "2.1.263"; private const string DefaultCommand = "claude"; diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeTranscriptPath.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeTranscriptPath.cs index bac9f9e9b..2ed7051ab 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeTranscriptPath.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Claude/ClaudeTranscriptPath.cs @@ -6,14 +6,17 @@ namespace CodeSpace.Core.Services.Agents.Harnesses.Claude; /// Reproduces Claude Code's transcript-file location so a CONTINUE can RESTORE a prior session's JSONL where the CLI /// looks for it on --resume. Claude stores a session at /// <CLAUDE_CONFIG_DIR>/projects/<sanitized-cwd>/<session-id>.jsonl. The sanitizer is a BYTE-FOR-BYTE -/// port of the real claude 2.1.193 encoder (extracted from the binary): replace every char outside [A-Za-z0-9] +/// port of the real claude 2.1.263 encoder (extracted from the binary): replace every char outside [A-Za-z0-9] /// with -, and when the result exceeds 200 chars truncate to 200 and append -<base36 hash> of the /// ORIGINAL cwd (so deep paths still map to a stable, collision-resistant dir). Pinned by ClaudeTranscriptPathTests -/// against ground-truth pairs produced by the real algorithm. +/// against ground-truth pairs produced by the real algorithm. Unchanged since the 2.1.193 pin this port was first taken +/// from — only the minifier's symbol names moved (ab/Byu/hRe/pXe became RA/Te/gz/az), and +/// defaultPath() still builds the segment as RA(cwd) under projects/. /// -/// function ab(e){let t=e.replace(/[^a-zA-Z0-9]/g,"-");if(t.length<=200)return t;return `${t.slice(0,200)}-${Byu(e)}`} -/// function Byu(e){return Math.abs(hRe(e)).toString(36)} -/// function hRe(e){let t=0;for(let n=0;n<e.length;n++)t=(t<<5)-t+e.charCodeAt(n)|0;return t} +/// function RA(e){let n=k(e);if(n.length<=az)return n;return `${n.slice(0,az)}-${Te(e)}`} // az=200 +/// function k(e){return e.replace(/[^a-zA-Z0-9]/g,"-")} +/// function Te(e){return Math.abs(gz(e)).toString(36)} +/// function gz(t){let e=0;for(let r=0;r<t.length;r++)e=(e<<5)-e+t.charCodeAt(r)|0;return e} /// /// The sharpest P3 hazard: the cwd MUST be the RESOLVED real path the agent process runs in — on macOS /// /var/… resolves to /private/var/…, and under bubblewrap it is the --chdir host path. Encoding the diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentNativeRecordPumpTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentNativeRecordPumpTests.cs index 57d555887..8fd34163a 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentNativeRecordPumpTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentNativeRecordPumpTests.cs @@ -247,7 +247,7 @@ public async Task A_plane_that_refuses_to_open_or_to_write_leaves_the_parse_path [Theory] [InlineData("codex-cli", "0.142.2", 1, "codex-cli/v1")] [InlineData("codex-cli", "0.142.2", null, "codex-cli/v1")] - [InlineData("claude-code", "2.1.193", 2, "claude-code/v2")] + [InlineData("claude-code", "2.1.263", 2, "claude-code/v2")] [InlineData("claude-code", "3.0.0", 2, "claude-code/v2")] [InlineData("scripted", "9.9.9", 2, "scripted/v2")] [InlineData("scripted", "2.0.0", null, "scripted/v1")] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeTranscriptPathTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeTranscriptPathTests.cs index a2ab00bec..cfd50ad14 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeTranscriptPathTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ClaudeTranscriptPathTests.cs @@ -6,7 +6,7 @@ namespace CodeSpace.UnitTests.Workflows; /// /// 🟢 Unit: reproduces Claude Code's transcript-file location so a CONTINUE can /// RESTORE a prior session's JSONL where the CLI looks for it on --resume. The known-pairs are the LOAD-BEARING -/// pin: they are REAL ~/.claude/projects directory names observed on a machine running claude 2.1.193, so a +/// pin: they are REAL ~/.claude/projects directory names observed on a machine running claude 2.1.263, so a /// drift in the cwd→sanitized-dir encoding fails HERE at test time rather than as a silent failed real-CLI resume /// (the sharpest P3 hazard — a mismatch lands the transcript under the wrong dir and --resume cold-starts with /// no error). @@ -15,14 +15,17 @@ namespace CodeSpace.UnitTests.Workflows; public class ClaudeTranscriptPathTests { [Theory] - // Ground truth — a byte-exact port of the real claude 2.1.193 `ab()`: replace every char outside [A-Za-z0-9] with + // Ground truth — a byte-exact port of the real claude 2.1.263 `RA()`: replace every char outside [A-Za-z0-9] with // '-' (so '/', '.', AND '_' all become '-'); alphanumerics + existing '-' survive (they map to themselves). [InlineData("/Users/mars/Projects/CodeSpace", "-Users-mars-Projects-CodeSpace")] // real ~/.claude/projects dir [InlineData("/Users/mars/Projects/CodeSpace/backend/src/CodeSpace.Core", "-Users-mars-Projects-CodeSpace-backend-src-CodeSpace-Core")] // real dir; the '.' in CodeSpace.Core → '-' [InlineData("/private/var/folders/z7/qrtkqj255vs6dg3wjfkgcn380000gn/T/codespace-agent-workspaces/05e4e233e0c5482985cbddd01d1a72a4", "-private-var-folders-z7-qrtkqj255vs6dg3wjfkgcn380000gn-T-codespace-agent-workspaces-05e4e233e0c5482985cbddd01d1a72a4")] // resolved agent-workspace cwd (/private, not /var) - [InlineData("/Users/john_doe/my_project", "-Users-john-doe-my-project")] // UNDERSCORE → '-' (the real binary does NOT preserve '_') — ground truth via the extracted ab() + [InlineData("/Users/john_doe/my_project", "-Users-john-doe-my-project")] // UNDERSCORE → '-' (the real binary does NOT preserve '_') — ground truth via the extracted RA() [InlineData("/Users/a_b/x.y/z", "-Users-a-b-x-y-z")] // mixed '_' + '.' → '-' + // Observed live on 2.1.263: the real CLI, run from this cwd, created exactly this dir under its projects/ home. + // Covers the classes the pairs above miss — a SPACE and NON-ASCII (each UTF-16 unit → its own '-', so '项目' → '--'). + [InlineData("/private/tmp/cs enc/my_project.v2/项目-x", "-private-tmp-cs-enc-my-project-v2----x")] public void EncodeCwd_matches_the_real_claude_encoder(string cwd, string expected) => ClaudeTranscriptPath.EncodeCwd(cwd).ShouldBe(expected); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs index 1c98be67e..710ded2dd 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/HarnessVersionPinTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using CodeSpace.Core.Services.Agents.Harnesses.Claude; using CodeSpace.Core.Services.Agents.Harnesses.Codex; @@ -12,7 +13,9 @@ namespace CodeSpace.UnitTests.Workflows; /// 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. +/// surface — a developer's local install — is synced from the same ARG by deploy/sync-local-harnesses.sh. The +/// fourth is .github/workflows/real-model.yml's EXACT-pinned installs, the lanes that verify the CLI surface +/// the harness argv targets; they must name the shipped version or the gate certifies a CLI nobody runs. /// [Trait("Category", "Unit")] public class HarnessVersionPinTests @@ -25,6 +28,25 @@ public void Codex_default_version_matches_the_worker_dockerfile_pin() => public void Claude_default_version_matches_the_worker_dockerfile_pin() => DockerfileArg("CLAUDE_CODE_VERSION").ShouldBe(ClaudeCodeHarness.DefaultVersion); + /// + /// The FOURTH surface: real-model.yml's exact-pinned installs — the lanes whose whole job is to verify the + /// CLI surface the harness argv targets. An exact pin there that lags the worker's ARG means the gate certifies a + /// version the product does not ship, which is precisely the silent drift the other pins exist to prevent. Only + /// EXACT pins are asserted; the deliberately FLOATING @~2.1.0 lanes are excluded by the version pattern, so + /// they keep tracking the newest 2.1.x without failing here. + /// + [Fact] + public void Claude_exact_workflow_pins_match_the_worker_dockerfile_pin() + { + var pinned = DockerfileArg("CLAUDE_CODE_VERSION"); + var matches = Regex.Matches(File.ReadAllText(LocateRealModelWorkflow()), @"@anthropic-ai/claude-code@(\d+\.\d+\.\d+)"); + + matches.Count.ShouldBeGreaterThan(0, "real-model.yml must keep at least one exact-pinned claude-code install — the lane that verifies the CLI surface"); + + foreach (var version in matches.Select(m => m.Groups[1].Value).Distinct()) + version.ShouldBe(pinned, $"an exact '@anthropic-ai/claude-code@{version}' install in .github/workflows/real-model.yml lags CLAUDE_CODE_VERSION in backend/Dockerfile.worker — the lane would certify a CLI the worker image never installs"); + } + private static string DockerfileArg(string name) { var content = File.ReadAllText(LocateWorkerDockerfile()); @@ -34,14 +56,20 @@ private static string DockerfileArg(string name) return match.Groups[1].Value; } - private static string LocateWorkerDockerfile() + private static string LocateWorkerDockerfile() => LocateRepoFile("backend", "Dockerfile.worker"); + + private static string LocateRealModelWorkflow() => LocateRepoFile(".github", "workflows", "real-model.yml"); + + private static string LocateRepoFile(params string[] segments) { + var relative = Path.Combine(segments); + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) { - var candidate = Path.Combine(dir.FullName, "backend", "Dockerfile.worker"); + var candidate = Path.Combine(dir.FullName, relative); if (File.Exists(candidate)) return candidate; } - throw new FileNotFoundException("backend/Dockerfile.worker not found walking up from " + AppContext.BaseDirectory); + throw new FileNotFoundException($"{relative} not found walking up from {AppContext.BaseDirectory}"); } }