Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,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

Expand Down Expand Up @@ -2602,6 +2605,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: |
Expand All @@ -2616,6 +2620,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"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,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: Build immutable SSH daemon assets once for every app variant
if: needs.decide.outputs.build_only != 'true'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,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'
Expand Down
13 changes: 10 additions & 3 deletions CLI/CMUXCLI+CoderouterPassthrough.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,24 @@ extension CMUXCLI {

/// PATH first (`coderouter`, then `cr`), exactly as before, then the
/// installer's bin directory, so an install whose shell-profile line has
/// not reached this process still runs. No hit involves the network.
/// not reached this process still runs, then the copy bundled beside cmux.
/// No hit involves the network.
func resolveCoderouterExecutable(environment: [String: String]) -> String? {
for name in Self.coderouterExecutableNames {
if let path = resolveExecutableInPath(name, searchPath: environment["PATH"]) {
return path
}
}
return resolveExecutableInPath(
if let installed = resolveExecutableInPath(
"coderouter",
searchPath: Self.coderouterInstallBinDirectory(environment: environment).path
)
) {
return installed
}
guard let bundledDirectory = resolvedExecutableURL()?.deletingLastPathComponent().path else {
return nil
}
return resolveExecutableInPath("coderouter", searchPath: bundledDirectory)
}

// MARK: - Bootstrap
Expand Down
9 changes: 8 additions & 1 deletion cmuxTests/CLICoderouterBootstrapTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,20 @@ struct CLICoderouterBootstrapTests {
let socketPath: String

init() throws {
cliPath = try BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self)
root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-coderouter-bootstrap-\(UUID().uuidString)", isDirectory: true)
home = root.appendingPathComponent("home", isDirectory: true)
emptyPathDirectory = root.appendingPathComponent("empty-path", isDirectory: true)
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
try fileManager.createDirectory(at: emptyPathDirectory, withIntermediateDirectories: true)
// Exercise the missing-binary path even when the real app bundles CodeRouter.
let bin = root.appendingPathComponent("Bare.app/Contents/Resources/bin", isDirectory: true)
try fileManager.createDirectory(at: bin, withIntermediateDirectories: true)
cliPath = bin.appendingPathComponent("cmux").path
try fileManager.copyItem(
atPath: BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self),
toPath: cliPath
)
let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8)
socketPath = "/tmp/cli-crb-\(shortID).sock"
}
Expand Down
107 changes: 88 additions & 19 deletions cmuxTests/CLICoderouterCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -664,27 +664,96 @@ extension CLINotifyProcessIntegrationRegressionTests {
}

func testCoderouterUnknownVerbStillPassesThroughToTheInstalledCLI() throws {
// With an empty PATH and an empty HOME (the passthrough also looks in
// the installer's ~/.coderouter/bin) there is no `coderouter`/`cr` to
// run; 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,
"HOME": emptyPath.path,
"CFFIXED_USER_HOME": emptyPath.path,
],
waitForSocket: false
) { _, _ in nil }
let result = runProcess(
executablePath: cli.path,
arguments: ["coderouter", "accounts"],
environment: passthroughEnvironment(path: root.appendingPathComponent("empty-path").path),
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("CodeRouter CLI is not installed"), result.stderr)
}

/// 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["HOME"] = path
environment["CFFIXED_USER_HOME"] = path
environment.removeValue(forKey: "CODEROUTER_INSTALL")
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment.removeValue(forKey: "CMUX_SOCKET_PATH")
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 {
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, "\(name): \(result.stderr)")
XCTAssertEqual(result.stdout, "PATH-\(name) argv=add codex\n", name)
}
}

/// The fresh-Mac case: nothing on PATH, so `cmux coderouter <verb>` 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: root.appendingPathComponent("empty-path").path),
timeout: 5
)

XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout, "BUNDLED argv=login\n")
}
}
2 changes: 1 addition & 1 deletion docs/cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Environment:
| `automation` | Manage config-backed event rules: `list`, `show <id>`, dry-run `test <id> --event <json>`, `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 <name>`, `--session <id>`, `--workspace <id>`, `--surface <id>`, `--cwd <text>`. Overrides: `--state-dir <path>`, `--codex-home <path>`. Text output defaults to 100 results; `--limit <n>` 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 <status|machines|claude>` 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 with the `CMUX_*`/`CMUXD_*` environment stripped: `coderouter` or `cr` on PATH first, then the official installer's `~/.coderouter/bin/coderouter` (`$CODEROUTER_INSTALL/bin` when set), never with a network call. When neither exists and stdin and stderr are terminals, cmux shows the documented installer `curl -fsSL https://cmux.com/coderouter/install.sh | sh`, says what it does (checksum-verified binary into `~/.coderouter/bin`, PATH line in the shell profile), asks once (`Install CodeRouter now? [y/N]`), and after `y` fetches the script, runs it with `sh`, and execs the new install with the original arguments. Any other outcome (non-interactive, declined, download or installer failure) prints that install command on stderr and exits 127. |
| `coderouter`, `cr` | `cmux coderouter <status|machines|claude>` 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 with the `CMUX_*`/`CMUXD_*` environment stripped: `coderouter` or `cr` on PATH first, then the official installer's `~/.coderouter/bin/coderouter` (`$CODEROUTER_INSTALL/bin` when set), then the copy bundled in `Contents/Resources/bin/coderouter` (pinned by `scripts/coderouter-cli-version`, installed by `scripts/install-coderouter-cli.sh`), never with a network call. When none exists and stdin and stderr are terminals, cmux shows the documented installer `curl -fsSL https://cmux.com/coderouter/install.sh | sh`, says what it does (checksum-verified binary into `~/.coderouter/bin`, PATH line in the shell profile), asks once (`Install CodeRouter now? [y/N]`), and after `y` fetches the script, runs it with `sh`, and execs the new install with the original arguments. Any other outcome (non-interactive, declined, download or installer failure) prints that install command on stderr and exits 127. |
| `vm`, `cloud` | Manage cloud VMs and their HTTPS publications. `cloud` is an alias for `vm`. |
| `cloud guide`, `cloud --skill` (also `vm guide`, `vm --skill`) | Print the same short Cloud guide without connecting to the app. `--json` returns `{topic: "cloud", format: "markdown", content: "..."}`. This does not install a skill or start an agent; `vm prompt` and its existing `vm skill` alias keep that behavior. |
| `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`. |
Expand Down
1 change: 1 addition & 0 deletions scripts/coderouter-cli-manifest.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cd0b8bbd69124d34a64d26c37a4fbb6d851b896e0f13aa5e3d1b9b48f8168a85
1 change: 1 addition & 0 deletions scripts/coderouter-cli-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.3.5
Loading
Loading