From bfa92eb0fa6c6cc428fa81f63ce059cb3a821311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 07:52:34 +0200 Subject: [PATCH 1/2] ci: add an opt-in Claude Code parity gate --- .github/workflows/cc-parity.yml | 105 +++++++++++++++ changelog.d/9346-cc-parity-gate.md | 1 + docs/src/SUMMARY.md | 1 + docs/src/testing/cc-parity.md | 101 +++++++++++++++ docs/src/testing/ci-tiers.md | 4 + scripts/cc_parity_gate.py | 202 +++++++++++++++++++++++++++++ tests/cc-parity/help.stdout | 72 ++++++++++ tests/cc-parity/manifest.json | 29 +++++ tests/cc-parity/version.stdout | 1 + tests/test_cc_parity_gate.py | 175 +++++++++++++++++++++++++ 10 files changed, 691 insertions(+) create mode 100644 .github/workflows/cc-parity.yml create mode 100644 changelog.d/9346-cc-parity-gate.md create mode 100644 docs/src/testing/cc-parity.md create mode 100644 scripts/cc_parity_gate.py create mode 100644 tests/cc-parity/help.stdout create mode 100644 tests/cc-parity/manifest.json create mode 100644 tests/cc-parity/version.stdout create mode 100644 tests/test_cc_parity_gate.py diff --git a/.github/workflows/cc-parity.yml b/.github/workflows/cc-parity.yml new file mode 100644 index 0000000000..0e92bcc7ef --- /dev/null +++ b/.github/workflows/cc-parity.yml @@ -0,0 +1,105 @@ +# Opt-in bundle-scale parity (#9346). Non-required until maintainers promote it. +name: cc-parity + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cc-parity-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + changes: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-cc-parity') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != pull_request ]; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + cc_files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') + # An empty listing must not quietly turn a requested gate green. + if [ -z "$cc_files" ] || grep -E '^(crates/|Cargo\.(toml|lock)$|rust-toolchain|\.cargo/|\.github/workflows/cc-parity\.yml$|scripts/cc_parity_gate\.py$|tests/(test_cc_parity_gate\.py$|cc-parity/))' <<< "$cc_files" > /dev/null; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + else + echo 'relevant=false' >> "$GITHUB_OUTPUT" + fi + + cc-parity: + needs: changes + if: needs.changes.outputs.relevant == 'true' + # The bundle's IR construction exceeds the ARM runner's 7 GB RAM. + runs-on: macos-15-intel + timeout-minutes: 90 + env: + CARGO_BUILD_JOBS: '4' + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Test the gate's failure paths + run: python3 -m unittest discover -s tests -p test_cc_parity_gate.py -v + + - name: Set scratch work directory + run: echo "CC_PARITY_WORK=$RUNNER_TEMP/cc-parity" >> "$GITHUB_ENV" + + # This job uses the macOS SDK, not the preinstalled simulator images. + # simctl unmounts runtime images before deleting their backing storage. + - name: Free simulator runtime disk space + run: | + sudo xcrun simctl runtime delete all + df -h / + + - name: Fetch and verify the pinned bundle + run: python3 scripts/cc_parity_gate.py prepare --work-dir "$CC_PARITY_WORK" + + - name: Install LLVM 22 + run: | + set -euo pipefail + brew install llvm@22 2>/dev/null || brew install llvm + cc_llvm_prefix="$(brew --prefix llvm@22 2>/dev/null || brew --prefix llvm)" + "$cc_llvm_prefix/bin/llvm-config" --version | grep -q '^22\.' + echo "LLVM_SYS_221_PREFIX=$cc_llvm_prefix" >> "$GITHUB_ENV" + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + shared-key: cc-parity-wasm-host + save-if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + + - name: Build compiler, then all runtime archives together + run: python3 scripts/cc_parity_gate.py build --work-dir "$CC_PARITY_WORK" + + - name: Compile the pinned bundle natively + run: python3 scripts/cc_parity_gate.py compile --timeout 4500 --work-dir "$CC_PARITY_WORK" --perry "$GITHUB_WORKSPACE/target/perry-dev/perry" + + - name: Check help and version offline against golden bytes + run: python3 scripts/cc_parity_gate.py check --work-dir "$CC_PARITY_WORK" + + - name: Upload compiler logs and parity results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cc-parity-results + path: ${{ runner.temp }}/cc-parity/logs/ + if-no-files-found: warn + retention-days: 7 diff --git a/changelog.d/9346-cc-parity-gate.md b/changelog.d/9346-cc-parity-gate.md new file mode 100644 index 0000000000..33f88d749f --- /dev/null +++ b/changelog.d/9346-cc-parity-gate.md @@ -0,0 +1 @@ +Add an opt-in `run-cc-parity` CI gate that compiles pinned Claude Code 2.1.112 and checks native help/version output against offline Node goldens. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 6a929a8681..b85fd44cf8 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -155,6 +155,7 @@ - [Geisterhand (UI Fuzzer)](testing/geisterhand.md) - [Node Compatibility Matrix](testing/node-compat-matrix.md) - [CI Tiers (PR gate / sweep / full)](testing/ci-tiers.md) +- [Claude Code Bundle Parity](testing/cc-parity.md) - [CI Gate Scheduling](testing/ci-gate-scheduling.md) # CLI Reference diff --git a/docs/src/testing/cc-parity.md b/docs/src/testing/cc-parity.md new file mode 100644 index 0000000000..891dbf2c6f --- /dev/null +++ b/docs/src/testing/cc-parity.md @@ -0,0 +1,101 @@ +# Claude Code bundle parity + +The `cc-parity` workflow compiles the standalone Claude Code **2.1.112** npm +bundle with Perry and compares native `--help` and `--version` stdout with +checked-in Node output. It covers the bundle-scale regressions described in +[#9346](https://github.com/PerryTS/perry/issues/9346). + +## Opt in + +Apply **`run-cc-parity`** to a PR changing compiler/runtime crates, build inputs, +or the gate itself. The workflow also supports manual dispatch. Unlabelled PRs +skip every job; labelled documentation-only PRs skip the expensive job. A new +commit supersedes the previous run on the same PR. + +This starts as a **non-required** check. Adding it to branch protection is a +separate maintainer decision after successful hosted runs. It has no push, +schedule, or release-tag trigger and is independent of `run-extended-tests`. + +The expensive job uses one `macos-15-intel` runner. Its +[14 GB RAM allocation](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) +provides more headroom for bundle IR construction than the 7 GB ARM runner. +The job removes unused simulator images and disables Cargo incremental artifacts +to leave disk space for LLVM and the native archives. The issue estimated 25–40 +minutes for bundle compilation; local validation took **57 minutes 25 seconds** +on macOS arm64 with five LLVM workers. The hosted Intel run with four workers +remains to be measured. Allow additional time for toolchain setup, especially on +a cold cache. The job has a 90-minute cap, compilation a 75-minute cap, and each +CLI invocation a 60-second cap. Timings are recorded for diagnosis, not compared +with a performance threshold. + +## What the check proves + +`tests/cc-parity/manifest.json` pins the npm tarball and extracted `package/cli.js` +by both size and SHA-256. Only that regular file is extracted; no package install +hooks run. The compiler is built first, then the runtime, stdlib, Wasm host, and +all native extension archives are built together with `perry-runtime/wasm-host`. +This avoids stale runtime copies in extension archives (#6303). Compilation uses +`--no-auto-optimize --no-cache --enable-wasm-runtime`, with four LLVM workers +(`PERRY_CODEGEN_UNIT_JOBS=4`) to use the Intel runner's four cores within its +memory budget. + +The runtime arm requires a native Mach-O executable. Each invocation gets its own +temporary HOME, XDG directories, working directory, and TMPDIR, with a small +environment allowlist and no inherited credentials or compiler tuning knobs. +macOS `sandbox-exec` denies network access; the gate fails if that sandbox is +unavailable. The harness tests include an attempted connection to prove the +network restriction is active. + +Both commands must exit zero before their deadlines and produce exactly the +golden bytes: **9,175 bytes** for help and **22 bytes** for version. A crash, +timeout, empty output, or one-byte difference fails. The manifest also pins the +goldens themselves, so changing a golden without updating its identity fails. + +Downloading the bundle, LLVM, and Rust dependencies requires network access +during setup. The two CLI executions are offline and use no Node installation +or API key. The artifact contains source identity, build/compile logs, actual +stdout/stderr, and JSON results; it excludes the downloaded bundle and executable. + +## Run locally on macOS + +From the repository root, with LLVM 22 and the pinned Rust toolchain available: + +```bash +export LLVM_SYS_221_PREFIX="$(brew --prefix llvm@22)" +export CARGO_BUILD_JOBS=4 +cc_work="$(mktemp -d)" +python3 -m unittest discover -s tests -p test_cc_parity_gate.py -v +python3 scripts/cc_parity_gate.py prepare --work-dir "$cc_work" +python3 scripts/cc_parity_gate.py build --work-dir "$cc_work" +python3 scripts/cc_parity_gate.py compile --timeout 4500 --work-dir "$cc_work" --perry "$PWD/target/perry-dev/perry" +python3 scripts/cc_parity_gate.py check --work-dir "$cc_work" +``` + +If using `CARGO_TARGET_DIR`, pass the compiler in that directory instead. A local +tarball can be supplied to `prepare --archive `; the same hashes are still +required. Inspect `$cc_work/logs/` for output differences and failure details. +Never run the bundle using your regular HOME: Claude can write its configuration +even on startup paths. + +## Refresh the pin and oracle deliberately + +2.1.112 is a standalone `cli.js` release. A newer package may have a different +distribution shape; confirm it still supplies the full bundle before changing +the pin. Update the manifest's version, URL, archive identity, and bundle identity +from the exact public npm tarball, then run `prepare` again. + +The recorded reference used Node **v26.5.1** on macOS arm64. To verify that oracle +with the same scratch environment and network sandbox: + +```bash +python3 scripts/cc_parity_gate.py check --work-dir "$cc_work" --node "$(command -v node)" +``` + +This writes `logs/node-help.stdout`, `logs/node-version.stdout`, and +`logs/node-parity.json`. A deliberate version refresh may fail the old golden +comparison; inspect both command results, require zero exit codes and no timeout, +and review the output changes before copying those two stdout files into +`tests/cc-parity/`. Update their byte counts and SHA-256 values and the oracle +provenance in the manifest. Rerun the Node check, harness tests, native compilation, +and native check. Commit the manifest and goldens together; never accept output +from a failing native executable as the new oracle. diff --git a/docs/src/testing/ci-tiers.md b/docs/src/testing/ci-tiers.md index 0e291d9e08..318f20d420 100644 --- a/docs/src/testing/ci-tiers.md +++ b/docs/src/testing/ci-tiers.md @@ -141,6 +141,10 @@ window (`previous sweep SHA .. this sweep SHA`), exactly as for the six-hourly g ## Opting a PR into more +- **`run-cc-parity` label** — runs the [Claude Code bundle parity gate](cc-parity.md) + on changes to crates, build inputs, or the gate. This independent, initially + non-required check compiles pinned Claude Code 2.1.112 and compares offline + native help/version output with checked-in Node goldens on one macOS runner. - **`run-extended-tests` label** — promotes the PR's `test.yml` run to the `full` tier AND enables the PR arm of every satellite gate. Use it for GC / codegen changes that should be measured before merge, and for anything touching a diff --git a/scripts/cc_parity_gate.py b/scripts/cc_parity_gate.py new file mode 100644 index 0000000000..4828d93d17 --- /dev/null +++ b/scripts/cc_parity_gate.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Pinned Claude Code native parity gate. Runtime checks require macOS Seatbelt.""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +CORPUS = ROOT / "tests/cc-parity" +SANDBOX = ["/usr/bin/sandbox-exec", "-p", "(version 1) (allow default) (deny network*)"] +CASES = ("help", "version") + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def verify(data, expected, description): + if len(data) != expected["bytes"] or digest(data) != expected["sha256"]: + raise ValueError(f"{description}: size/SHA-256 mismatch") + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def prepare(work, manifest, archive_path=None): + if archive_path: + archive = archive_path.read_bytes() + else: + with urllib.request.urlopen(manifest["archive"]["url"], timeout=120) as response: + archive = response.read() + verify(archive, manifest["archive"], "npm archive") + # Never extract paths, symlinks, or executable package hooks from the archive. + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as package: + member = package.getmember("package/cli.js") + if not member.isfile(): + raise ValueError("package/cli.js is not a regular file") + with package.extractfile(member) as source: + bundle = source.read() + verify(bundle, manifest["bundle"], "cli.js") + (work / "cli.js").write_bytes(bundle) + write_json(work / "logs/source.json", manifest) + + +def build_toolchain(work): + metadata = json.loads(subprocess.check_output( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], cwd=ROOT + )) + base = ["cargo", "build", "--locked", "--profile", "perry-dev"] + # The compiler uses the default runtime without external Wasm symbols. + commands = [base + ["-p", "perry"]] + packages = ["perry-runtime-static", "perry-stdlib-static", "perry-wasm-host"] + packages += sorted(p["name"] for p in metadata["packages"] if p["name"].startswith("perry-ext-")) + # Unify wasm-host in EVERY archive embedding runtime code (#6303). + runtime = base + ["--features", "perry-runtime/wasm-host"] + for package in packages: + runtime += ["-p", package] + commands.append(runtime) + with (work / "logs/build.log").open("w") as log: + for command in commands: + print(" ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, check=True) + + +def run_logged(command, cwd, env, stdout, stderr, timeout): + started = time.monotonic() + with stdout.open("wb") as out, stderr.open("wb") as err: + process = subprocess.Popen( + command, cwd=cwd, env=env, stdout=out, stderr=err, start_new_session=True + ) + timed_out = False + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(process.pid, signal.SIGKILL) + process.wait() + return { + "exit_code": process.returncode, + "timed_out": timed_out, + "seconds": round(time.monotonic() - started, 3), + "stdout_bytes": stdout.stat().st_size, + "stdout_sha256": digest(stdout.read_bytes()), + } + + +def compile_bundle(work, manifest, perry, timeout): + verify((work / "cli.js").read_bytes(), manifest["bundle"], "cli.js") + binary = work / "claude-native" + binary.unlink(missing_ok=True) # A failed rebuild must never reuse an old executable. + env = {key: value for key, value in os.environ.items() if not key.startswith("PERRY_")} + env.update(PERRY_RUNTIME_DIR=str(perry.parent), PERRY_NO_AUTO_OPTIMIZE="1", PERRY_NO_CACHE="1", + PERRY_CODEGEN_UNIT_JOBS="4") + command = [str(perry), "compile", "--no-auto-optimize", "--no-cache", + "--enable-wasm-runtime", str(work / "cli.js"), "-o", str(binary)] + result = run_logged(command, work, env, work / "logs/compile.stdout", + work / "logs/compile.stderr", timeout) + write_json(work / "logs/compile.json", {"command": command, **result}) + if result["exit_code"] != 0 or result["timed_out"]: + raise ValueError("native compilation failed; see logs/compile.stderr and compile.json") + require_native(binary) + + +def require_native(binary): + # This gate runs on macOS; a Node wrapper must not satisfy the native arm. + with binary.open("rb") as executable: + magic = executable.read(4) + if magic not in (b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf"): + raise ValueError(f"{binary}: expected a 64-bit Mach-O executable") + if not os.access(binary, os.X_OK): + raise ValueError(f"{binary}: not executable") + + +def scratch_env(directory): + # Do not inherit credentials, user configuration, or caller's PERRY_* knobs. + return { + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": str(directory), + "XDG_CONFIG_HOME": str(directory / "config"), + "XDG_CACHE_HOME": str(directory / "cache"), + "XDG_STATE_HOME": str(directory / "state"), + "TMPDIR": str(directory), + "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TERM": "dumb", + "CI": "1", "NO_COLOR": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + } + + +def check(work, manifest, corpus=CORPUS, timeout=60, node=None): + if sys.platform != "darwin" or not Path(SANDBOX[0]).is_file(): + raise ValueError("offline execution requires macOS sandbox-exec; no unsandboxed fallback") + if node: + verify((work / "cli.js").read_bytes(), manifest["bundle"], "cli.js") + command = [str(node), str(work / "cli.js")] + prefix = "node-" + else: + binary = work / "claude-native" + require_native(binary) + command = [str(binary)] + prefix = "" + report = {"bundle_sha256": manifest["bundle"]["sha256"], "cases": {}} + for case in CASES: + expected = (corpus / f"{case}.stdout").read_bytes() + verify(expected, manifest["goldens"][case], f"{case} golden") + stdout = work / f"logs/{prefix}{case}.stdout" + stderr = work / f"logs/{prefix}{case}.stderr" + with tempfile.TemporaryDirectory(prefix=f"cc-parity-{case}-") as scratch: + directory = Path(scratch) + result = run_logged(SANDBOX + command + [f"--{case}"], directory, + scratch_env(directory), stdout, stderr, timeout) + result["matches_golden"] = stdout.read_bytes() == expected + result["passed"] = (result["exit_code"] == 0 and not result["timed_out"] + and result["matches_golden"]) + report["cases"][case] = result + print(f"{case}: {'PASS' if result['passed'] else 'FAIL'} {json.dumps(result)}", flush=True) + write_json(work / f"logs/{prefix}parity.json", report) + if not all(result["passed"] for result in report["cases"].values()): + raise ValueError("Claude Code parity failed; compare logs/*.stdout with tests/cc-parity/*.stdout") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("prepare", "build", "compile", "check")) + parser.add_argument("--work-dir", type=Path, required=True) + parser.add_argument("--archive", type=Path, help="use a local pinned npm archive during prepare") + parser.add_argument("--perry", type=Path, help="fresh compiler next to coherently built runtime archives") + parser.add_argument("--node", type=Path, help="check the Node oracle locally instead of the native executable") + parser.add_argument("--timeout", type=float, help="seconds; default compile 3600, check 60") + args = parser.parse_args() + work = args.work_dir.resolve() + (work / "logs").mkdir(parents=True, exist_ok=True) + manifest = json.loads((CORPUS / "manifest.json").read_text()) + try: + if args.command == "prepare": + prepare(work, manifest, args.archive) + elif args.command == "build": + build_toolchain(work) + elif args.command == "compile": + if args.perry is None: + parser.error("compile requires --perry") + compile_bundle(work, manifest, args.perry.resolve(), args.timeout or 3600) + else: + check(work, manifest, timeout=args.timeout or 60, + node=args.node.resolve() if args.node else None) + except (OSError, ValueError, tarfile.TarError, subprocess.CalledProcessError) as error: + print(f"cc-parity: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/cc-parity/help.stdout b/tests/cc-parity/help.stdout new file mode 100644 index 0000000000..123e61db2d --- /dev/null +++ b/tests/cc-parity/help.stdout @@ -0,0 +1,72 @@ +Usage: claude [options] [command] [prompt] + +Claude Code - starts an interactive session by default, use -p/--print for +non-interactive output + +Arguments: + prompt Your prompt + +Options: + --add-dir Additional directories to allow tool access to + --agent Agent for the current session. Overrides the 'agent' setting. + --agents JSON object defining custom agents (e.g. '{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}') + --allow-dangerously-skip-permissions Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access. + --allowedTools, --allowed-tools Comma or space-separated list of tool names to allow (e.g. "Bash(git *) Edit") + --append-system-prompt Append a system prompt to the default system prompt + --bare Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir. + --betas Beta headers to include in API requests (API key users only) + --brief Enable SendUserMessage tool for agent-to-user communication + --chrome Enable Claude in Chrome integration + -c, --continue Continue the most recent conversation in the current directory + --dangerously-skip-permissions Bypass all permission checks. Recommended only for sandboxes with no internet access. + -d, --debug [filter] Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file") + --debug-file Write debug logs to a specific file path (implicitly enables debug mode) + --disable-slash-commands Disable all skills + --disallowedTools, --disallowed-tools Comma or space-separated list of tool names to deny (e.g. "Bash(git *) Edit") + --effort Effort level for the current session (low, medium, high, xhigh, max) + --exclude-dynamic-system-prompt-sections Move per-machine sections (cwd, env info, memory paths, git status) from the system prompt into the first user message. Improves cross-user prompt-cache reuse. Only applies with the default system prompt (ignored with --system-prompt). (default: false) + --fallback-model Enable automatic fallback to specified model when default model is overloaded (only works with --print) + --file File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png) + --fork-session When resuming, create a new session ID instead of reusing the original (use with --resume or --continue) + --from-pr [value] Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term + -h, --help Display help for command + --ide Automatically connect to IDE on startup if exactly one valid IDE is available + --include-hook-events Include all hook lifecycle events in the output stream (only works with --output-format=stream-json) + --include-partial-messages Include partial message chunks as they arrive (only works with --print and --output-format=stream-json) + --input-format Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input) (choices: "text", "stream-json") + --json-schema JSON Schema for structured output validation. Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]} + --max-budget-usd Maximum dollar amount to spend on API calls (only works with --print) + --mcp-config Load MCP servers from JSON files or strings (space-separated) + --mcp-debug [DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors) + --model Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6'). + -n, --name Set a display name for this session (shown in /resume and terminal title) + --no-chrome Disable Claude in Chrome integration + --no-session-persistence Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print) + --output-format Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming) (choices: "text", "json", "stream-json") + --permission-mode Permission mode to use for the session (choices: "acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan") + --plugin-dir Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B) (default: []) + -p, --print Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust. + --remote-control-session-name-prefix Prefix for auto-generated Remote Control session names (default: hostname) + --replay-user-messages Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json) + -r, --resume [value] Resume a conversation by session ID, or open interactive picker with optional search term + --session-id Use a specific session ID for the conversation (must be a valid UUID) + --setting-sources Comma-separated list of setting sources to load (user, project, local). + --settings Path to a settings JSON file or a JSON string to load additional settings from + --strict-mcp-config Only use MCP servers from --mcp-config, ignoring all other MCP configurations + --system-prompt System prompt to use for the session + --tmux Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux. + --tools Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read"). + --verbose Override verbose mode setting from config + -v, --version Output the version number + -w, --worktree [name] Create a new git worktree for this session (optionally specify a name) + +Commands: + agents [options] List configured agents + auth Manage authentication + auto-mode Inspect auto mode classifier configuration + doctor Check the health of your Claude Code auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust. + install [options] [target] Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version) + mcp Configure and manage MCP servers + plugin|plugins Manage Claude Code plugins + setup-token Set up a long-lived authentication token (requires Claude subscription) + update|upgrade Check for updates and install if available diff --git a/tests/cc-parity/manifest.json b/tests/cc-parity/manifest.json new file mode 100644 index 0000000000..90bdc3ab0b --- /dev/null +++ b/tests/cc-parity/manifest.json @@ -0,0 +1,29 @@ +{ + "package": "@anthropic-ai/claude-code", + "version": "2.1.112", + "archive": { + "url": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.112.tgz", + "bytes": 18679326, + "sha256": "84379969ea53a0e5fd231a8f77debe4c7cb17dd971f4809d10d33f9aeca5de09" + }, + "bundle": { + "bytes": 13711684, + "sha256": "bc3358282800e3e99daa8e71ac5b7b1566bd0d7ca7eb94f714a7859365d3163f" + }, + "oracle": { + "node": "v26.5.1", + "platform": "darwin-arm64", + "captured": "2026-09-05", + "network": "denied by sandbox-exec" + }, + "goldens": { + "help": { + "bytes": 9175, + "sha256": "6cdb361880002e66c20e00de48ef13170c0b90185a69f651748f21958ab47094" + }, + "version": { + "bytes": 22, + "sha256": "4d9d156e4f0af416a02d325d3aab4dc084c09c2b78cf189ef63e1f13b8a1833e" + } + } +} diff --git a/tests/cc-parity/version.stdout b/tests/cc-parity/version.stdout new file mode 100644 index 0000000000..d1ad175c12 --- /dev/null +++ b/tests/cc-parity/version.stdout @@ -0,0 +1 @@ +2.1.112 (Claude Code) diff --git a/tests/test_cc_parity_gate.py b/tests/test_cc_parity_gate.py new file mode 100644 index 0000000000..de4b736a2a --- /dev/null +++ b/tests/test_cc_parity_gate.py @@ -0,0 +1,175 @@ +"""Exercise the gate with deliberately broken archives and native executables.""" + +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location( + "cc_parity_gate", Path(__file__).resolve().parents[1] / "scripts/cc_parity_gate.py" +) +gate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gate) + + +def identity(data): + return {"bytes": len(data), "sha256": gate.digest(data)} + + +class GateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.work = Path(self.temp.name) + (self.work / "logs").mkdir() + self.manifest = {"bundle": identity(b"bundle"), "goldens": {}} + for case, data in (("help", b"help\n"), ("version", b"version\n")): + (self.work / f"{case}.stdout").write_bytes(data) + self.manifest["goldens"][case] = identity(data) + + def archive(self, source=b"bundle", symlink=False): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + member = tarfile.TarInfo("package/cli.js") + member.size = len(source) + if symlink: + member.type = tarfile.SYMTYPE + member.linkname = "../../outside" + archive.addfile(member, io.BytesIO(source)) + data = buffer.getvalue() + path = self.work / "package.tgz" + path.write_bytes(data) + self.manifest["archive"] = identity(data) + return path + + def test_prepare_checks_both_hashes(self): + archive = self.archive() + gate.prepare(self.work, self.manifest, archive) + self.assertEqual((self.work / "cli.js").read_bytes(), b"bundle") + archive.write_bytes(archive.read_bytes() + b"changed") + with self.assertRaisesRegex(ValueError, "npm archive"): + gate.prepare(self.work, self.manifest, archive) + archive = self.archive(b"different bundle") + with self.assertRaisesRegex(ValueError, "cli.js"): + gate.prepare(self.work, self.manifest, archive) + + def test_prepare_rejects_symlink(self): + with self.assertRaisesRegex(ValueError, "regular file"): + gate.prepare(self.work, self.manifest, self.archive(symlink=True)) + + def test_rejects_script_in_native_arm(self): + binary = self.work / "claude-native" + binary.write_text("#!/bin/sh\necho help\n") + binary.chmod(0o755) + with self.assertRaisesRegex(ValueError, "Mach-O"): + gate.require_native(binary) + + def test_failed_compile_cannot_reuse_a_stale_binary(self): + (self.work / "cli.js").write_bytes(b"bundle") + binary = self.work / "claude-native" + binary.write_bytes(b"old executable") + compiler = self.work / "failing-perry" + compiler.write_text("#!/bin/sh\necho deliberate compiler failure >&2\nexit 2\n") + compiler.chmod(0o755) + with self.assertRaisesRegex(ValueError, "native compilation failed"): + gate.compile_bundle(self.work, self.manifest, compiler, timeout=5) + self.assertFalse(binary.exists()) + report = json.loads((self.work / "logs/compile.json").read_text()) + self.assertEqual(report["exit_code"], 2) + + def test_scratch_environment_is_an_allowlist(self): + env = gate.scratch_env(self.work) + self.assertEqual(env["HOME"], str(self.work)) + self.assertEqual(env["TMPDIR"], str(self.work)) + self.assertNotIn("ANTHROPIC_API_KEY", env) + self.assertFalse(any(key.startswith("PERRY_") for key in env)) + self.assertNotIn("/opt/homebrew/bin", env["PATH"]) + + def test_checked_in_golden_integrity(self): + manifest = json.loads((gate.CORPUS / "manifest.json").read_text()) + self.assertEqual(manifest["goldens"]["help"]["bytes"], 9175) + for case in gate.CASES: + gate.verify((gate.CORPUS / f"{case}.stdout").read_bytes(), + manifest["goldens"][case], case) + + +@unittest.skipUnless(sys.platform == "darwin", "native offline gate uses macOS Seatbelt") +class NativeGateTests(unittest.TestCase): + setUp = GateTests.setUp + + def native(self, behavior=""): + source = self.work / "fixture.c" + source.write_text('''#include +#include +#include +#include +#include +#include +#include +int main(int argc, char **argv) { + if (argc != 2 || getenv("ANTHROPIC_API_KEY") || getenv("PERRY_TEST_KNOB")) return 8; + if (!getenv("HOME") || !strstr(getenv("HOME"), "cc-parity-")) return 9; + // Prove Seatbelt is live, even though these fixtures need no connection. + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) { + struct sockaddr_in address = {0}; + address.sin_family = AF_INET; + address.sin_port = htons(9); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int result = connect(fd, (struct sockaddr *)&address, sizeof(address)); + int error = errno; + close(fd); + if (result != -1 || error != EPERM) return 10; + } else if (errno != EPERM) return 11; + ''' + behavior + ''' + puts(strcmp(argv[1], "--help") == 0 ? "help" : "version"); + return 0; +} +''') + subprocess.run(["/usr/bin/cc", str(source), "-o", str(self.work / "claude-native")], + check=True, capture_output=True) + + def check(self, timeout=5): + gate.check(self.work, self.manifest, corpus=self.work, timeout=timeout) + + def test_native_exact_bytes_pass_with_network_denied(self): + self.native() + self.check() + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertEqual(set(report["cases"]), {"help", "version"}) + self.assertTrue(all(case["passed"] for case in report["cases"].values())) + + def test_one_byte_difference_fails(self): + self.native('putchar(\'!\');') + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check() + + def test_nonzero_exit_fails_even_with_matching_stdout(self): + self.native('puts(strcmp(argv[1], "--help") == 0 ? "help" : "version"); return 3;') + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check() + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertTrue(report["cases"]["help"]["matches_golden"]) + + def test_timeout_fails(self): + self.native("sleep(10);") + with self.assertRaisesRegex(ValueError, "parity failed"): + self.check(timeout=0.2) + report = json.loads((self.work / "logs/parity.json").read_text()) + self.assertTrue(report["cases"]["help"]["timed_out"]) + + def test_changed_golden_fails_before_execution(self): + self.native() + (self.work / "help.stdout").write_bytes(b"incorrect golden\n") + with self.assertRaisesRegex(ValueError, "help golden"): + self.check() + self.assertFalse((self.work / "logs/help.stdout").exists()) + + +if __name__ == "__main__": + unittest.main() From 4904850db18d1b0cff0621b0b5ea768e284cd05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 07:53:17 +0200 Subject: [PATCH 2/2] docs: number the Claude Code parity changeset --- changelog.d/{9346-cc-parity-gate.md => 9793-cc-parity-gate.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9346-cc-parity-gate.md => 9793-cc-parity-gate.md} (100%) diff --git a/changelog.d/9346-cc-parity-gate.md b/changelog.d/9793-cc-parity-gate.md similarity index 100% rename from changelog.d/9346-cc-parity-gate.md rename to changelog.d/9793-cc-parity-gate.md