From 389c21ff039f2c483fac114eef48e10cab5236f6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:46:05 -0700 Subject: [PATCH 1/5] test(cli): cmux coderouter passthrough prefers PATH and falls back to a bundled CodeRouter Fails on main: nothing installs Contents/Resources/bin/coderouter and the passthrough only searches PATH, so a fresh Mac gets exit 127. Claude-Session: https://claude.ai/code/session_01BhWEaLQcb61c4Q6dnjv3e5 --- cmuxTests/CLICoderouterCommandTests.swift | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cmuxTests/CLICoderouterCommandTests.swift b/cmuxTests/CLICoderouterCommandTests.swift index 36f3e7bc31a4..ec91547b34ba 100644 --- a/cmuxTests/CLICoderouterCommandTests.swift +++ b/cmuxTests/CLICoderouterCommandTests.swift @@ -455,4 +455,65 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(result.status, 127, result.stderr) XCTAssertTrue(state.commands.isEmpty, "passthrough verbs must not touch the cmux socket: \(state.commands)") } + + /// A stand-in CodeRouter that prints how it was invoked, so a test can tell + /// which copy the passthrough exec'd. + private func writeFakeCoderouter(at url: URL, marker: String) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try """ + #!/bin/sh + printf '%s argv=%s\\n' "\(marker)" "$*" + """.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func passthroughEnvironment(path: String) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment.removeValue(forKey: "CMUX_SOCKET_PATH") + return environment + } + + func testCoderouterPassthroughPrefersTheUserInstallOnPath() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-coderouter-path-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeFakeCoderouter(at: root.appendingPathComponent("bin/cr"), marker: "PATH-CR") + + let result = runProcess( + executablePath: try bundledCLIPath(), + arguments: ["cr", "add", "codex"], + environment: passthroughEnvironment(path: "\(root.path)/bin:/usr/bin:/bin"), + timeout: 5 + ) + + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "PATH-CR argv=add codex\n") + } + + /// The fresh-Mac case: nothing on PATH, so `cmux coderouter ` execs the + /// CodeRouter bundled beside the cmux CLI in Contents/Resources/bin. + func testCoderouterPassthroughFallsBackToTheBundledCopy() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-coderouter-bundled-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let bin = root.appendingPathComponent("Fresh.app/Contents/Resources/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + // A copy, not a symlink: the CLI resolves its own path with realpath, so + // a symlink would point back at the DerivedData bundle. + let cli = bin.appendingPathComponent("cmux") + try FileManager.default.copyItem(atPath: try bundledCLIPath(), toPath: cli.path) + try writeFakeCoderouter(at: bin.appendingPathComponent("coderouter"), marker: "BUNDLED") + + let result = runProcess( + executablePath: cli.path, + arguments: ["coderouter", "login"], + environment: passthroughEnvironment(path: "/usr/bin:/bin"), + timeout: 5 + ) + + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "BUNDLED argv=login\n") + } } From 111c0fbe55a8f36bdef9a8f0e73b6d2fe79d4ff2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:46:05 -0700 Subject: [PATCH 2/5] cli: bundle the CodeRouter CLI so cmux coderouter works on a fresh Mac A fresh Mac has no coderouter, cr, claude, codex, or Node, so every passthrough verb (login, add codex, accounts) exited 127 and the only route to a working Cloud machine was an out-of-band install. cmux now ships CodeRouter inside the app like the cmux-tui client: - scripts/install-coderouter-cli.sh downloads both darwin slices of the release pinned in scripts/coderouter-cli-version from the public manaflow-ai/coderouter-releases repo, verifies them against that release's manifest.json, lipos one universal binary into Contents/Resources/bin/coderouter, and probes it with the credential-free `capabilities --json`. CMUX_CODEROUTER_CLI_LOCAL installs a prebuilt binary offline. - reload.sh, ci.yml release-build, nightly.yml and release.yml install it beside the cmux-tui client; the CI slice check verifies both archs and the probe. sign-cmux-bundle.sh already signs every Mach-O helper under Resources/bin. - The CLI passthrough resolves a user install on PATH first, then the bundled copy, so an explicit newer install still wins. Claude-Session: https://claude.ai/code/session_01BhWEaLQcb61c4Q6dnjv3e5 --- .github/workflows/ci.yml | 8 ++ .github/workflows/nightly.yml | 2 + .github/workflows/release.yml | 2 + CLI/CMUXCLI+Coderouter.swift | 31 ++++++++ CLI/cmux.swift | 10 +-- docs/cli-contract.md | 2 +- scripts/coderouter-cli-version | 1 + scripts/install-coderouter-cli.sh | 110 +++++++++++++++++++++++++++ scripts/reload.sh | 9 +++ tests/test_install_coderouter_cli.sh | 69 +++++++++++++++++ 10 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 scripts/coderouter-cli-version create mode 100755 scripts/install-coderouter-cli.sh create mode 100755 tests/test_install_coderouter_cli.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04c9183bca5..4905170f462e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,6 +302,9 @@ jobs: - name: Validate cmux-tui client installation run: bash ./tests/test_install_cmux_tui_client.sh + - name: Validate coderouter CLI installation + run: bash ./tests/test_install_coderouter_cli.sh + - name: Validate nightly notarization behavior run: ./tests/test_notarize_nightly_dmg.sh @@ -2364,6 +2367,7 @@ jobs: ghostty-cli-helper/ghostty \ build-universal/Build/Products/Release/cmux.app ./scripts/install-cmux-tui-client.sh build-universal/Build/Products/Release/cmux.app + ./scripts/install-coderouter-cli.sh build-universal/Build/Products/Release/cmux.app - name: Validate Release artifact slices run: | @@ -2378,6 +2382,10 @@ jobs: TUI_CLIENT="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux-tui" test -x "$TUI_CLIENT" lipo "$TUI_CLIENT" -verify_arch arm64 x86_64 + CODEROUTER_CLI="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/coderouter" + test -x "$CODEROUTER_CLI" + for arch in arm64 x86_64; do lipo "$CODEROUTER_CLI" -verify_arch "$arch"; done + "$CODEROUTER_CLI" capabilities --json | grep -q '"product":"coderouter"' test -x "$HELPER_BINARY" test -x "$CMUX_CUA_BINARY" test -x "$DIFF_SIDECAR" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index eda4bc1169e8..b428c4f378ea 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -457,6 +457,8 @@ jobs: --expected-commit "$cmux_tui_commit" \ --require-capability wireguard-hub fi + # CodeRouter rides along so `cmux coderouter` works on a fresh Mac. + ./scripts/install-coderouter-cli.sh "$app" - name: Strip unsigned nightly app before transfer run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e6a6d126ded..82ab9e79f847 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -333,6 +333,8 @@ jobs: --manifest-url "https://files.cmux.com/cmux-tui/${cmux_tui_commit}/manifest.json" \ --expected-commit "$cmux_tui_commit" \ --require-capability wireguard-hub + # CodeRouter rides along so `cmux coderouter` works on a fresh Mac. + ./scripts/install-coderouter-cli.sh build-universal/Build/Products/Release/cmux.app - name: Verify binary architectures if: steps.guard_release_assets.outputs.skip_all != 'true' diff --git a/CLI/CMUXCLI+Coderouter.swift b/CLI/CMUXCLI+Coderouter.swift index ea2b4d337bdd..a587e3850713 100644 --- a/CLI/CMUXCLI+Coderouter.swift +++ b/CLI/CMUXCLI+Coderouter.swift @@ -71,6 +71,37 @@ extension CMUXCLI { return cmuxOwnedCoderouterVerbs.contains(first) } + /// Where `cmux coderouter ` and `cmux cr ...` find CodeRouter, + /// in order: a user-installed `coderouter` or `cr` on PATH wins (an explicit + /// install may be newer than ours), then the copy bundled beside this CLI + /// (`Contents/Resources/bin/coderouter`, installed by + /// scripts/install-coderouter-cli.sh) so a fresh Mac needs no separate install, + /// no Node, and no PATH setup for `cmux coderouter login` or `cmux cr add codex`. + func coderouterExecutableCandidates(environment: [String: String] = ProcessInfo.processInfo.environment) -> [String] { + var candidates: [String] = [] + for dir in (environment["PATH"] ?? "").split(separator: ":") where !dir.isEmpty { + let base = URL(fileURLWithPath: String(dir), isDirectory: true) + candidates.append(base.appendingPathComponent("coderouter", isDirectory: false).path) + candidates.append(base.appendingPathComponent("cr", isDirectory: false).path) + } + if let bundled = resolvedExecutableURL()?.deletingLastPathComponent().appendingPathComponent("coderouter", isDirectory: false).path { + candidates.append(bundled) + } + return candidates + } + + func locateCoderouterExecutable(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let fm = FileManager.default + return coderouterExecutableCandidates(environment: environment).first { candidate in + // `isExecutableFile(atPath:)` is true for directories, so a directory named + // like the binary would otherwise shadow the real executable (#8743). + var isDirectory: ObjCBool = false + return fm.fileExists(atPath: candidate, isDirectory: &isDirectory) + && !isDirectory.boolValue + && fm.isExecutableFile(atPath: candidate) + } + } + func runCoderouterCommand(commandArgs: [String], client: SocketClient, jsonOutput: Bool) throws { let sub = commandArgs.first?.lowercased() ?? "help" let rest = Array(commandArgs.dropFirst()) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e560525f6509..a0f992e02ea5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5044,17 +5044,15 @@ struct CMUXCLI { return explicitValue == defaultValue ? catalogValue : explicitValue } - /// Run the separately installed CodeRouter CLI without routing through the - /// cmux socket. Replace this process after resolving the executable so + /// Run the CodeRouter CLI (a user install on PATH, else the copy bundled in + /// Contents/Resources/bin) without routing through the cmux socket. + /// Replace this process after resolving the executable so /// stdin/stdout/stderr, signals, and the child exit status retain their /// normal terminal semantics. The argv is built directly; arguments such /// as prompts, paths, and shell metacharacters are never interpreted by a /// shell. private func runCoderouterAlias(commandArgs: [String]) throws { - let candidates = ["coderouter", "cr"] - guard let executablePath = candidates.lazy - .compactMap({ resolveExecutableInPath($0) }) - .first else { + guard let executablePath = locateCoderouterExecutable() else { throw CLIError( message: localizedCoderouterNotFound(), exitCode: 127 diff --git a/docs/cli-contract.md b/docs/cli-contract.md index cb7e23c6e5cd..696f128d01a2 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -89,7 +89,7 @@ Environment: | `automation` | Manage config-backed event rules: `list`, `show `, dry-run `test --event `, `enable`, `disable`, `logs`, and `reload`. Rules live in `~/.cmuxterm/automations.json`; actions are dispatched by the running app. | | `sessions [list]` | List saved agent session records without requiring a running cmux socket. Filters: `--agent `, `--session `, `--workspace `, `--surface `, `--cwd `. Overrides: `--state-dir `, `--codex-home `. Text output defaults to 100 results; `--limit ` takes a positive integer and `--all` removes the limit. Supports `--json`. | | `auth` | Manage auth status, login, and logout through the app. | -| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the installed CodeRouter CLI (`coderouter` or `cr` on PATH) unchanged, exit 127 when it is missing. | +| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the CodeRouter CLI unchanged: a user install (`coderouter` or `cr` on PATH) first, else the copy bundled in `Contents/Resources/bin/coderouter` (pinned by `scripts/coderouter-cli-version`, installed by `scripts/install-coderouter-cli.sh`), so a fresh Mac needs no separate install. Exit 127 only when neither exists. | | `vm`, `cloud` | Manage cloud VMs. `cloud` is an alias for `vm`. | | `remotes`, `remote` | Manage remote Macs in the team device registry so they appear in the iOS app's device list. `remote` is an alias for `remotes`. | | `rpc` | Call a raw v2 socket method with optional JSON params. | diff --git a/scripts/coderouter-cli-version b/scripts/coderouter-cli-version new file mode 100644 index 000000000000..c2c0004f0e2a --- /dev/null +++ b/scripts/coderouter-cli-version @@ -0,0 +1 @@ +0.3.5 diff --git a/scripts/install-coderouter-cli.sh b/scripts/install-coderouter-cli.sh new file mode 100755 index 000000000000..d3d3f37590db --- /dev/null +++ b/scripts/install-coderouter-cli.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Installs the CodeRouter CLI into an app bundle as Contents/Resources/bin/coderouter, +# the same way the cmux-tui client is bundled: a fresh Mac gets `cmux coderouter ...` +# and `cmux cr ...` with no separate install, no Node, and no PATH setup. +# +# The build comes from the public manaflow-ai/coderouter-releases GitHub release named +# in scripts/coderouter-cli-version (or CMUX_CODEROUTER_CLI_VERSION). Both darwin +# slices are downloaded, sha256-verified against that release's manifest.json, and +# lipo'd into one universal binary. Downloads are cached per version. +# +# scripts/install-coderouter-cli.sh [--version ] [--cache-dir ] +# +# Env: CMUX_CODEROUTER_CLI_VERSION overrides the pinned version, CMUX_CODEROUTER_CLI_LOCAL +# points at a prebuilt binary to install instead of downloading (offline/dev builds), +# CMUX_CODEROUTER_CLI_BASE_URL overrides the release download base (tests, mirrors). +set -euo pipefail + +usage() { sed -n '2,15p' "$0"; } + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_PATH="" +VERSION="${CMUX_CODEROUTER_CLI_VERSION:-}" +CACHE_DIR="${CMUX_CODEROUTER_CLI_CACHE:-$HOME/Library/Caches/cmux/coderouter-cli}" +while (( $# )); do + case "$1" in + --version) shift; VERSION="${1:?--version needs a value}" ;; + --cache-dir) shift; CACHE_DIR="${1:?--cache-dir needs a value}" ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $1" >&2; usage >&2; exit 64 ;; + *) APP_PATH="$1" ;; + esac + shift +done +[[ -n "$APP_PATH" && -d "$APP_PATH/Contents" ]] || { echo "error: app bundle not found at '${APP_PATH:-}'" >&2; exit 1; } +if [[ -z "$VERSION" ]]; then + VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/coderouter-cli-version")" +fi +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]] || { echo "error: invalid coderouter version '$VERSION'" >&2; exit 1; } +DEST_DIR="$APP_PATH/Contents/Resources/bin" +DEST="$DEST_DIR/coderouter" +mkdir -p "$DEST_DIR" + +sha256_of() { shasum -a 256 "$1" | awk '{print $1}'; } + +# `coderouter capabilities --json` needs no login and no network; it is the +# credential-free probe the CLI documents for scripts. +verify_probe() { + local probe + probe="$("$DEST" capabilities --json 2>/dev/null || true)" + [[ "$probe" == *'"product":"coderouter"'* ]] || { + echo "error: installed binary does not probe as coderouter: $probe" >&2 + exit 1 + } +} + +if [[ -n "${CMUX_CODEROUTER_CLI_LOCAL:-}" ]]; then + [[ -f "$CMUX_CODEROUTER_CLI_LOCAL" ]] || { echo "error: CMUX_CODEROUTER_CLI_LOCAL not found: $CMUX_CODEROUTER_CLI_LOCAL" >&2; exit 1; } + install -m 755 "$CMUX_CODEROUTER_CLI_LOCAL" "$DEST" + verify_probe + echo "Installed local coderouter CLI at $DEST" + exit 0 +fi + +BASE="${CMUX_CODEROUTER_CLI_BASE_URL:-https://github.com/manaflow-ai/coderouter-releases/releases/download}/v$VERSION" +BUILD_DIR="$CACHE_DIR/$VERSION" +mkdir -p "$BUILD_DIR" +MANIFEST="$BUILD_DIR/manifest.json" + +fetch() { # + curl --proto '=https,file' --tlsv1.2 -fsSL --retry 3 --retry-delay 2 "$1" -o "$2" +} + +if [[ ! -f "$MANIFEST" ]]; then + fetch "$BASE/manifest.json" "$MANIFEST.tmp" + mv -f "$MANIFEST.tmp" "$MANIFEST" +fi +MANIFEST_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")" +[[ "$MANIFEST_VERSION" == "$VERSION" ]] || { + echo "error: coderouter manifest version mismatch (expected $VERSION, got $MANIFEST_VERSION)" >&2 + exit 1 +} + +fetch_slice() { # -> path + local name="$1" want got out + want="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["binaries"].get(sys.argv[2], ""))' "$MANIFEST" "$name")" + [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "error: manifest lacks $name" >&2; exit 1; } + out="$BUILD_DIR/$name" + if [[ -f "$out" ]] && [[ "$(sha256_of "$out")" == "$want" ]]; then + printf '%s' "$out"; return + fi + fetch "$BASE/$name" "$out.tmp" + got="$(sha256_of "$out.tmp")" + [[ "$got" == "$want" ]] || { echo "error: sha256 mismatch for $name (want $want, got $got)" >&2; rm -f "$out.tmp"; exit 1; } + mv -f "$out.tmp" "$out" + printf '%s' "$out" +} + +ARM="$(fetch_slice coderouter-darwin-arm64)" +X64="$(fetch_slice coderouter-darwin-x64)" +UNIVERSAL="$BUILD_DIR/coderouter-universal" +if [[ ! -f "$UNIVERSAL" ]]; then + lipo -create "$ARM" "$X64" -output "$UNIVERSAL.tmp" + mv -f "$UNIVERSAL.tmp" "$UNIVERSAL" +fi +install -m 755 "$UNIVERSAL" "$DEST" +# One arch per invocation: some lipo builds (Xcode 27 beta 4) consume only one +# arch after -verify_arch and read the second as an extra input file. +for arch in arm64 x86_64; do lipo "$DEST" -verify_arch "$arch"; done +verify_probe +echo "Installed universal coderouter CLI $VERSION at $DEST" diff --git a/scripts/reload.sh b/scripts/reload.sh index 25130646635b..fe1aa7510916 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -1764,6 +1764,15 @@ else fi "$PWD/scripts/install-cmux-tui-client.sh" "${cmux_tui_install_args[@]}" fi +# CodeRouter ships inside the bundle too, so `cmux coderouter ...` and `cmux cr ...` +# work on a fresh Mac with nothing on PATH. The version is pinned in +# scripts/coderouter-cli-version; CMUX_SKIP_CODEROUTER_CLI=1 keeps an existing copy +# for offline reloads. +if [[ "${CMUX_SKIP_CODEROUTER_CLI:-}" == "1" && -x "$APP_PATH/Contents/Resources/bin/coderouter" ]]; then + echo "Preserving bundled coderouter CLI (CMUX_SKIP_CODEROUTER_CLI=1)" +else + "$PWD/scripts/install-coderouter-cli.sh" "$APP_PATH" +fi if command -v xattr >/dev/null 2>&1; then xattr -cr "$APP_PATH" || true fi diff --git a/tests/test_install_coderouter_cli.sh b/tests/test_install_coderouter_cli.sh new file mode 100755 index 000000000000..2bf6e91bd343 --- /dev/null +++ b/tests/test_install_coderouter_cli.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-coderouter-install.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +APP="$TEST_DIR/Test.app" +mkdir -p "$APP/Contents" + +# A stand-in binary that answers the credential-free probe the installer checks. +CLIENT="$TEST_DIR/coderouter" +cat > "$CLIENT" <<'SH' +#!/bin/sh +[ "$1" = capabilities ] && [ "$2" = --json ] || exit 64 +printf '%s\n' '{"product":"coderouter","cliVersion":"0.0.0","protocolVersion":1,"authModes":["standalone-stack"],"features":[]}' +SH +chmod +x "$CLIENT" +BOGUS="$TEST_DIR/not-coderouter" +printf '#!/bin/sh\nexit 0\n' > "$BOGUS" +chmod +x "$BOGUS" + +# Local install path (offline/dev builds), through the runner's system Bash 3.2. +CMUX_CODEROUTER_CLI_LOCAL="$CLIENT" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" +cmp "$CLIENT" "$APP/Contents/Resources/bin/coderouter" +if CMUX_CODEROUTER_CLI_LOCAL="$BOGUS" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" > "$TEST_DIR/bogus.log" 2>&1; then + echo "FAIL: installed a binary that does not probe as coderouter" >&2 + exit 1 +fi +grep -q 'does not probe as coderouter' "$TEST_DIR/bogus.log" + +# Download path against a local file:// release mirror: manifest version check, +# sha256 verification, and lipo into one universal binary. Needs cc and lipo, so +# it runs on macOS only; the Linux guard job covers the local path above. +if [[ "$(uname -s)" == "Darwin" ]]; then +RELEASE="$TEST_DIR/releases/v9.9.9" +mkdir -p "$RELEASE" +ARM_SRC="$(mktemp "$TEST_DIR/arm.XXXXXX.c")" +printf 'int main(void){return 0;}\n' > "$ARM_SRC" +cc -arch arm64 -o "$RELEASE/coderouter-darwin-arm64" "$ARM_SRC" +cc -arch x86_64 -o "$RELEASE/coderouter-darwin-x64" "$ARM_SRC" +sha() { shasum -a 256 "$1" | awk '{print $1}'; } +cat > "$RELEASE/manifest.json" < "$TEST_DIR/download.log" 2>&1; then + echo "FAIL: probe should have rejected the stand-in universal binary" >&2 + exit 1 +fi +grep -q 'does not probe as coderouter' "$TEST_DIR/download.log" +lipo "$TEST_DIR/cache/9.9.9/coderouter-universal" -verify_arch arm64 +lipo "$TEST_DIR/cache/9.9.9/coderouter-universal" -verify_arch x86_64 + +# A tampered slice must fail its checksum before anything is installed. +printf 'x' >> "$RELEASE/coderouter-darwin-x64" +rm -rf "$TEST_DIR/cache" +if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" \ + --version 9.9.9 --cache-dir "$TEST_DIR/cache" > "$TEST_DIR/tamper.log" 2>&1; then + echo "FAIL: installed a slice with a bad checksum" >&2 + exit 1 +fi +grep -q 'sha256 mismatch for coderouter-darwin-x64' "$TEST_DIR/tamper.log" +fi + +# The pinned version file is well formed. +grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' "$ROOT_DIR/scripts/coderouter-cli-version" +echo "PASS: coderouter CLI installation (local, probe, manifest, checksum, universal)" From be72af66ca6e5af9cb7be4d85b0ebaca271f6e64 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:18:41 -0700 Subject: [PATCH 3/5] test(cli): make the missing-CodeRouter passthrough test deterministic The old version ran the CLI from DerivedData with an empty PATH and a mock socket it never waited on, which XCTest reports as an unwaited expectation, and a bundled coderouter beside the CLI would now satisfy the lookup. Copy the CLI into a bare fake .app with nothing on PATH and assert the 127 exit and its message directly. Claude-Session: https://claude.ai/code/session_01BhWEaLQcb61c4Q6dnjv3e5 --- cmuxTests/CLICoderouterCommandTests.swift | 32 ++++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/cmuxTests/CLICoderouterCommandTests.swift b/cmuxTests/CLICoderouterCommandTests.swift index ec91547b34ba..6f7187782df0 100644 --- a/cmuxTests/CLICoderouterCommandTests.swift +++ b/cmuxTests/CLICoderouterCommandTests.swift @@ -438,22 +438,28 @@ extension CLINotifyProcessIntegrationRegressionTests { } func testCoderouterUnknownVerbStillPassesThroughToTheInstalledCLI() throws { - // With an empty PATH the passthrough cannot find `coderouter`/`cr`; the - // point is that the socket is never consulted for a non-cmux verb. - let emptyPath = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-empty-path-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: emptyPath, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: emptyPath) } + // No `coderouter`/`cr` on PATH and none bundled beside the CLI: the + // passthrough exits 127 before the socket is ever consulted. The CLI is + // copied into a bare fake .app so a bundled copy in DerivedData cannot + // satisfy the lookup, and no socket path is set, so a socket round trip + // would surface as a connection error rather than 127. + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-coderouter-missing-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let bin = root.appendingPathComponent("Bare.app/Contents/Resources/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let cli = bin.appendingPathComponent("cmux") + try FileManager.default.copyItem(atPath: try bundledCLIPath(), toPath: cli.path) - let (result, state) = try runCoderouterCLI( - ["coderouter", "accounts"], - socketName: "coderouter-passthrough", - extraEnvironment: ["PATH": emptyPath.path], - waitForSocket: false - ) { _, _ in nil } + let result = runProcess( + executablePath: cli.path, + arguments: ["coderouter", "accounts"], + environment: passthroughEnvironment(path: "/usr/bin:/bin"), + timeout: 5 + ) XCTAssertEqual(result.status, 127, result.stderr) - XCTAssertTrue(state.commands.isEmpty, "passthrough verbs must not touch the cmux socket: \(state.commands)") + XCTAssertTrue(result.stderr.contains("Required CLI not found"), result.stderr) } /// A stand-in CodeRouter that prints how it was invoked, so a test can tell From 3b6f7e81a2f240a3470d15a5295f688f080dca9d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:05:27 -0700 Subject: [PATCH 4/5] coderouter installer: pin the manifest digest, strict probe, bounded curl; isolate test PATH Review follow-ups: the release manifest is now verified against a digest pinned in scripts/coderouter-cli-manifest.sha256 (independent of the download host); the capabilities probe requires exit 0 and parsed JSON; curl gets connect and total timeouts; the PATH-first test covers both executable names on a single-directory PATH and the other two use an empty PATH; the CLI contract table cell escapes its pipes. --- cmuxTests/CLICoderouterCommandTests.swift | 35 +++++++++-------- docs/cli-contract.md | 2 +- scripts/coderouter-cli-manifest.sha256 | 1 + scripts/install-coderouter-cli.sh | 47 ++++++++++++++++++----- tests/test_install_coderouter_cli.sh | 33 ++++++++++++++-- 5 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 scripts/coderouter-cli-manifest.sha256 diff --git a/cmuxTests/CLICoderouterCommandTests.swift b/cmuxTests/CLICoderouterCommandTests.swift index 6f7187782df0..9d37eaaaa8e3 100644 --- a/cmuxTests/CLICoderouterCommandTests.swift +++ b/cmuxTests/CLICoderouterCommandTests.swift @@ -454,7 +454,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let result = runProcess( executablePath: cli.path, arguments: ["coderouter", "accounts"], - environment: passthroughEnvironment(path: "/usr/bin:/bin"), + environment: passthroughEnvironment(path: root.appendingPathComponent("empty-path").path), timeout: 5 ) @@ -481,21 +481,26 @@ extension CLINotifyProcessIntegrationRegressionTests { return environment } + /// Both executable names a user install can carry win over the bundled copy. + /// PATH is exactly one temp directory so a real install on this Mac cannot + /// leak into the lookup; the fake needs only /bin/sh builtins. func testCoderouterPassthroughPrefersTheUserInstallOnPath() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-coderouter-path-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - try writeFakeCoderouter(at: root.appendingPathComponent("bin/cr"), marker: "PATH-CR") - - let result = runProcess( - executablePath: try bundledCLIPath(), - arguments: ["cr", "add", "codex"], - environment: passthroughEnvironment(path: "\(root.path)/bin:/usr/bin:/bin"), - timeout: 5 - ) + for name in ["cr", "coderouter"] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-coderouter-path-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try writeFakeCoderouter(at: root.appendingPathComponent("bin/\(name)"), marker: "PATH-\(name)") + + let result = runProcess( + executablePath: try bundledCLIPath(), + arguments: ["cr", "add", "codex"], + environment: passthroughEnvironment(path: "\(root.path)/bin"), + timeout: 5 + ) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "PATH-CR argv=add codex\n") + XCTAssertEqual(result.status, 0, "\(name): \(result.stderr)") + XCTAssertEqual(result.stdout, "PATH-\(name) argv=add codex\n", name) + } } /// The fresh-Mac case: nothing on PATH, so `cmux coderouter ` execs the @@ -515,7 +520,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let result = runProcess( executablePath: cli.path, arguments: ["coderouter", "login"], - environment: passthroughEnvironment(path: "/usr/bin:/bin"), + environment: passthroughEnvironment(path: root.appendingPathComponent("empty-path").path), timeout: 5 ) diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 696f128d01a2..4059cd9f4ef8 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -89,7 +89,7 @@ Environment: | `automation` | Manage config-backed event rules: `list`, `show `, dry-run `test --event `, `enable`, `disable`, `logs`, and `reload`. Rules live in `~/.cmuxterm/automations.json`; actions are dispatched by the running app. | | `sessions [list]` | List saved agent session records without requiring a running cmux socket. Filters: `--agent `, `--session `, `--workspace `, `--surface `, `--cwd `. Overrides: `--state-dir `, `--codex-home `. Text output defaults to 100 results; `--limit ` takes a positive integer and `--all` removes the limit. Supports `--json`. | | `auth` | Manage auth status, login, and logout through the app. | -| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the CodeRouter CLI unchanged: a user install (`coderouter` or `cr` on PATH) first, else the copy bundled in `Contents/Resources/bin/coderouter` (pinned by `scripts/coderouter-cli-version`, installed by `scripts/install-coderouter-cli.sh`), so a fresh Mac needs no separate install. Exit 127 only when neither exists. | +| `coderouter`, `cr` | `cmux coderouter ` manages the team's coderouter model plane through the app (sign-in state, per-machine usage, the team's Claude upstream accounts). Every other `cmux coderouter ...` verb and all of `cmux cr ...` exec the CodeRouter CLI unchanged: a user install (`coderouter` or `cr` on PATH) first, else the copy bundled in `Contents/Resources/bin/coderouter` (pinned by `scripts/coderouter-cli-version`, installed by `scripts/install-coderouter-cli.sh`), so a fresh Mac needs no separate install. Exit 127 only when neither exists. | | `vm`, `cloud` | Manage cloud VMs. `cloud` is an alias for `vm`. | | `remotes`, `remote` | Manage remote Macs in the team device registry so they appear in the iOS app's device list. `remote` is an alias for `remotes`. | | `rpc` | Call a raw v2 socket method with optional JSON params. | diff --git a/scripts/coderouter-cli-manifest.sha256 b/scripts/coderouter-cli-manifest.sha256 new file mode 100644 index 000000000000..b522c85f90c3 --- /dev/null +++ b/scripts/coderouter-cli-manifest.sha256 @@ -0,0 +1 @@ +cd0b8bbd69124d34a64d26c37a4fbb6d851b896e0f13aa5e3d1b9b48f8168a85 diff --git a/scripts/install-coderouter-cli.sh b/scripts/install-coderouter-cli.sh index d3d3f37590db..dc785498351e 100755 --- a/scripts/install-coderouter-cli.sh +++ b/scripts/install-coderouter-cli.sh @@ -4,9 +4,12 @@ # and `cmux cr ...` with no separate install, no Node, and no PATH setup. # # The build comes from the public manaflow-ai/coderouter-releases GitHub release named -# in scripts/coderouter-cli-version (or CMUX_CODEROUTER_CLI_VERSION). Both darwin -# slices are downloaded, sha256-verified against that release's manifest.json, and -# lipo'd into one universal binary. Downloads are cached per version. +# in scripts/coderouter-cli-version (or CMUX_CODEROUTER_CLI_VERSION). The release's +# manifest.json is verified against the digest pinned in +# scripts/coderouter-cli-manifest.sha256 (or CMUX_CODEROUTER_CLI_MANIFEST_SHA256), both +# darwin slices are sha256-verified against that manifest, and lipo'd into one +# universal binary. Downloads are cached per version. Bump the version and the +# manifest digest together (the digest is in the release's SHA256SUMS). # # scripts/install-coderouter-cli.sh [--version ] [--cache-dir ] # @@ -15,7 +18,7 @@ # CMUX_CODEROUTER_CLI_BASE_URL overrides the release download base (tests, mirrors). set -euo pipefail -usage() { sed -n '2,15p' "$0"; } +usage() { sed -n '2,18p' "$0"; } SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_PATH="" @@ -43,14 +46,18 @@ mkdir -p "$DEST_DIR" sha256_of() { shasum -a 256 "$1" | awk '{print $1}'; } # `coderouter capabilities --json` needs no login and no network; it is the -# credential-free probe the CLI documents for scripts. +# credential-free probe the CLI documents for scripts. The command must exit 0 +# and print JSON whose product is coderouter. verify_probe() { local probe - probe="$("$DEST" capabilities --json 2>/dev/null || true)" - [[ "$probe" == *'"product":"coderouter"'* ]] || { + if ! probe="$("$DEST" capabilities --json 2>/dev/null)"; then + echo "error: installed binary does not probe as coderouter (capabilities --json failed): $probe" >&2 + exit 1 + fi + if ! python3 -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("product") == "coderouter" else 1)' "$probe" 2>/dev/null; then echo "error: installed binary does not probe as coderouter: $probe" >&2 exit 1 - } + fi } if [[ -n "${CMUX_CODEROUTER_CLI_LOCAL:-}" ]]; then @@ -67,11 +74,31 @@ mkdir -p "$BUILD_DIR" MANIFEST="$BUILD_DIR/manifest.json" fetch() { # - curl --proto '=https,file' --tlsv1.2 -fsSL --retry 3 --retry-delay 2 "$1" -o "$2" + curl --proto '=https,file' --tlsv1.2 -fsSL --retry 3 --retry-delay 2 \ + --connect-timeout 20 --max-time 300 "$1" -o "$2" +} + +# The manifest and the binaries come from the same host, so the manifest's own +# digest is pinned in the repo (scripts/coderouter-cli-manifest.sha256) as the +# independent trust root. A version that is not the pinned one needs its digest +# through CMUX_CODEROUTER_CLI_MANIFEST_SHA256. +MANIFEST_SHA256="${CMUX_CODEROUTER_CLI_MANIFEST_SHA256:-}" +if [[ -z "$MANIFEST_SHA256" && "$VERSION" == "$(tr -d '[:space:]' < "$SCRIPT_DIR/coderouter-cli-version")" ]]; then + MANIFEST_SHA256="$(tr -d '[:space:]' < "$SCRIPT_DIR/coderouter-cli-manifest.sha256")" +fi +[[ "$MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] || { + echo "error: no pinned manifest digest for coderouter $VERSION; set CMUX_CODEROUTER_CLI_MANIFEST_SHA256 or bump scripts/coderouter-cli-version and scripts/coderouter-cli-manifest.sha256 together" >&2 + exit 1 } -if [[ ! -f "$MANIFEST" ]]; then +if [[ ! -f "$MANIFEST" ]] || [[ "$(sha256_of "$MANIFEST")" != "$MANIFEST_SHA256" ]]; then fetch "$BASE/manifest.json" "$MANIFEST.tmp" + got="$(sha256_of "$MANIFEST.tmp")" + [[ "$got" == "$MANIFEST_SHA256" ]] || { + echo "error: sha256 mismatch for manifest.json (want $MANIFEST_SHA256, got $got)" >&2 + rm -f "$MANIFEST.tmp" + exit 1 + } mv -f "$MANIFEST.tmp" "$MANIFEST" fi MANIFEST_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")" diff --git a/tests/test_install_coderouter_cli.sh b/tests/test_install_coderouter_cli.sh index 2bf6e91bd343..eab44ebfbee5 100755 --- a/tests/test_install_coderouter_cli.sh +++ b/tests/test_install_coderouter_cli.sh @@ -18,6 +18,10 @@ chmod +x "$CLIENT" BOGUS="$TEST_DIR/not-coderouter" printf '#!/bin/sh\nexit 0\n' > "$BOGUS" chmod +x "$BOGUS" +# Prints the right product but exits non-zero: the probe must check the status. +LIAR="$TEST_DIR/liar" +printf '#!/bin/sh\nprintf %%s "{\\"product\\":\\"coderouter\\"}"\nexit 1\n' > "$LIAR" +chmod +x "$LIAR" # Local install path (offline/dev builds), through the runner's system Bash 3.2. CMUX_CODEROUTER_CLI_LOCAL="$CLIENT" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" @@ -27,6 +31,11 @@ if CMUX_CODEROUTER_CLI_LOCAL="$BOGUS" /bin/bash "$ROOT_DIR/scripts/install-coder exit 1 fi grep -q 'does not probe as coderouter' "$TEST_DIR/bogus.log" +if CMUX_CODEROUTER_CLI_LOCAL="$LIAR" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" > "$TEST_DIR/liar.log" 2>&1; then + echo "FAIL: installed a binary whose probe exits non-zero" >&2 + exit 1 +fi +grep -q 'capabilities --json failed' "$TEST_DIR/liar.log" # Download path against a local file:// release mirror: manifest version check, # sha256 verification, and lipo into one universal binary. Needs cc and lipo, so @@ -42,9 +51,25 @@ sha() { shasum -a 256 "$1" | awk '{print $1}'; } cat > "$RELEASE/manifest.json" < "$TEST_DIR/nopin.log" 2>&1; then + echo "FAIL: installed a version with no pinned manifest digest" >&2 + exit 1 +fi +grep -q 'no pinned manifest digest' "$TEST_DIR/nopin.log" +# A wrong digest rejects the manifest. +if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" CMUX_CODEROUTER_CLI_MANIFEST_SHA256="$(printf 'x%.0s' {1..64} | tr x 0)" \ + /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" --version 9.9.9 --cache-dir "$TEST_DIR/cache" > "$TEST_DIR/badpin.log" 2>&1; then + echo "FAIL: accepted a manifest with the wrong digest" >&2 + exit 1 +fi +grep -q 'sha256 mismatch for manifest.json' "$TEST_DIR/badpin.log" # The stand-in slices cannot answer the probe, so this exercises everything up # to it and expects the probe itself to fail. -if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" \ +if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" CMUX_CODEROUTER_CLI_MANIFEST_SHA256="$MANIFEST_SHA" \ + /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" \ --version 9.9.9 --cache-dir "$TEST_DIR/cache" > "$TEST_DIR/download.log" 2>&1; then echo "FAIL: probe should have rejected the stand-in universal binary" >&2 exit 1 @@ -56,7 +81,8 @@ lipo "$TEST_DIR/cache/9.9.9/coderouter-universal" -verify_arch x86_64 # A tampered slice must fail its checksum before anything is installed. printf 'x' >> "$RELEASE/coderouter-darwin-x64" rm -rf "$TEST_DIR/cache" -if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" \ +if CMUX_CODEROUTER_CLI_BASE_URL="file://$TEST_DIR/releases" CMUX_CODEROUTER_CLI_MANIFEST_SHA256="$MANIFEST_SHA" \ + /bin/bash "$ROOT_DIR/scripts/install-coderouter-cli.sh" "$APP" \ --version 9.9.9 --cache-dir "$TEST_DIR/cache" > "$TEST_DIR/tamper.log" 2>&1; then echo "FAIL: installed a slice with a bad checksum" >&2 exit 1 @@ -64,6 +90,7 @@ fi grep -q 'sha256 mismatch for coderouter-darwin-x64' "$TEST_DIR/tamper.log" fi -# The pinned version file is well formed. +# The pin files are well formed. grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' "$ROOT_DIR/scripts/coderouter-cli-version" +grep -Eq '^[0-9a-f]{64}$' "$ROOT_DIR/scripts/coderouter-cli-manifest.sha256" echo "PASS: coderouter CLI installation (local, probe, manifest, checksum, universal)" From f27b50415cadfb21ad790e393ae0e94ebe74176c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:11:18 -0700 Subject: [PATCH 5/5] Silence the unmutated payload warning from #11931 so the warning budget passes --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString,