diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 905db623ca3f..c0fdd309daef 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3396,6 +3396,108 @@ struct CMUXCLI { return true } + private func localizedCoderouterAliases() -> String { + let defaultValue = "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)" + let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main + let catalogValue = String( + localized: "cli.coderouter.aliases", + defaultValue: "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)", + bundle: bundle + ) + let explicitValue = CMUXDiffViewerLocalization.string( + "cli.coderouter.aliases", + defaultValue: defaultValue + ) + return explicitValue == defaultValue ? catalogValue : explicitValue + } + + private func localizedCoderouterNotFound() -> String { + let defaultValue = "Required CLI not found. Install the command and retry." + let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main + let catalogValue = String( + localized: "cli.coderouter.error.notFound", + defaultValue: "Required CLI not found. Install the command and retry.", + bundle: bundle + ) + let explicitValue = CMUXDiffViewerLocalization.string( + "cli.coderouter.error.notFound", + defaultValue: defaultValue + ) + return explicitValue == defaultValue ? catalogValue : explicitValue + } + + private func localizedCoderouterLaunchFailed() -> String { + let defaultValue = "Could not start the required CLI. Check the installation and try again." + let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main + let catalogValue = String( + localized: "cli.coderouter.error.launchFailed", + defaultValue: "Could not start the required CLI. Check the installation and try again.", + bundle: bundle + ) + let explicitValue = CMUXDiffViewerLocalization.string( + "cli.coderouter.error.launchFailed", + defaultValue: defaultValue + ) + 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 + /// 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 { + throw CLIError( + message: localizedCoderouterNotFound(), + exitCode: 127 + ) + } + + // CodeRouter is an independent executable. Do not hand it cmux's ambient + // terminal/control-plane context: CMUX_* and CMUXD_* may carry socket + // paths, capabilities, passwords, auth state, or internal paths. There is + // intentionally no auth handoff here; a future handoff must be explicit + // and narrowly allowlisted. + let childEnvironment = ProcessInfo.processInfo.environment.filter { key, _ in + !key.hasPrefix("CMUX_") && !key.hasPrefix("CMUXD_") + } + var argv = ([executablePath] + commandArgs).map { strdup($0) } + let environmentStrings = childEnvironment.keys.sorted().map { key in + "\(key)=\(childEnvironment[key] ?? "")" + } + var environment = environmentStrings.map { strdup($0) } + defer { + for item in argv { + free(item) + } + for item in environment { + free(item) + } + } + argv.append(nil) + environment.append(nil) + + let executionError = cliExecFailureErrno { + executablePath.withCString { executable in + _ = execve(executable, &argv, &environment) + } + } + let errorText = String(cString: strerror(executionError)) + cliDebugLog( + "cli.coderouter.exec_failed executable=\(executablePath) " + + "errno=\(executionError) error=\(errorText)" + ) + throw CLIError( + message: localizedCoderouterLaunchFailed(), + exitCode: 127 + ) + } + func run() throws { let processEnv = ProcessInfo.processInfo.environment let cliBundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier() @@ -3465,6 +3567,10 @@ struct CMUXCLI { let command = args[index] let rawCommandArgs = Array(args[(index + 1)...]) + if command == "coderouter" || command == "cr" { + try runCoderouterAlias(commandArgs: rawCommandArgs) + return + } let passesThroughProviderArguments = managedProviderArgumentsPassThrough(command: command) let presentationOptions: (jsonOutput: Bool, idFormat: String?, remaining: [String]) if passesThroughProviderArguments { @@ -36722,6 +36828,7 @@ export default CMUXSessionRestore; events [--after ] [--cursor-file ] [--name ] [--category ] [--reconnect] [--limit ] [--no-ack] [--no-heartbeat] auth login | logout (aliases for auth login/logout) + \(localizedCoderouterAliases()) vm [args...] (alias: cloud) remotes [--route ] [--tag ] [--json] (alias: remote) ai-accounts [--team ] [--json] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 48d10e2a869d..be8ec4b261da 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -266470,6 +266470,81 @@ } } } + }, + "cli.coderouter.aliases": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (بدائل لـ CodeRouter CLI المثبّت)" } }, + "bs": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliasi za instalirani CodeRouter CLI)" } }, + "da": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliasser for den installerede CodeRouter CLI)" } }, + "de": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (Aliase für die installierte CodeRouter-CLI)" } }, + "en": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliases for the installed CodeRouter CLI)" } }, + "es": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (alias para la CLI de CodeRouter instalada)" } }, + "fr": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (alias de la CLI CodeRouter installée)" } }, + "it": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (alias per la CLI CodeRouter installata)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (インストール済み CodeRouter CLI のエイリアス)" } }, + "km": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (ឈ្មោះកាត់សម្រាប់ CodeRouter CLI ដែលបានដំឡើង)" } }, + "ko": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (설치된 CodeRouter CLI의 별칭)" } }, + "nb": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliaser for den installerte CodeRouter CLI)" } }, + "pl": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliasy zainstalowanego CodeRouter CLI)" } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (aliases para a CLI do CodeRouter instalada)" } }, + "ru": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (псевдонимы для установленного CodeRouter CLI)" } }, + "th": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (ชื่อเรียกแทนสำหรับ CodeRouter CLI ที่ติดตั้งไว้)" } }, + "tr": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (yüklü CodeRouter CLI için takma adlar)" } }, + "uk": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (псевдоніми для встановленого CodeRouter CLI)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (已安装 CodeRouter CLI 的别名)" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "coderouter|cr [coderouter-args...] (已安裝 CodeRouter CLI 的別名)" } } + } + }, + "cli.coderouter.error.launchFailed": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تعذر تشغيل أداة سطر الأوامر المطلوبة. تحقّق من التثبيت ثم أعد المحاولة." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Traženi CLI nije moguće pokrenuti. Provjerite instalaciju i pokušajte ponovo." } }, + "da": { "stringUnit": { "state": "translated", "value": "Den nødvendige CLI kunne ikke startes. Kontrollér installationen, og prøv igen." } }, + "de": { "stringUnit": { "state": "translated", "value": "Die erforderliche CLI konnte nicht gestartet werden. Überprüfen Sie die Installation und versuchen Sie es erneut." } }, + "en": { "stringUnit": { "state": "translated", "value": "Could not start the required CLI. Check the installation and try again." } }, + "es": { "stringUnit": { "state": "translated", "value": "No se pudo iniciar la CLI necesaria. Comprueba la instalación y vuelve a intentarlo." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Impossible de démarrer la CLI requise. Vérifiez l’installation, puis réessayez." } }, + "it": { "stringUnit": { "state": "translated", "value": "Impossibile avviare la CLI richiesta. Verifica l'installazione e riprova." } }, + "ja": { "stringUnit": { "state": "translated", "value": "必要な CLI を起動できませんでした。インストールを確認して、もう一度お試しください。" } }, + "km": { "stringUnit": { "state": "translated", "value": "មិនអាចចាប់ផ្ដើម CLI ដែលត្រូវការ។ សូមពិនិត្យការដំឡើង ហើយព្យាយាមម្ដងទៀត។" } }, + "ko": { "stringUnit": { "state": "translated", "value": "필요한 CLI를 시작할 수 없습니다. 설치를 확인한 후 다시 시도하세요." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Kunne ikke starte den nødvendige CLI-en. Kontroller installasjonen og prøv igjen." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Nie można uruchomić wymaganej biblioteki CLI. Sprawdź instalację i spróbuj ponownie." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "Não foi possível iniciar a CLI necessária. Verifique a instalação e tente novamente." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Не удалось запустить необходимый CLI. Проверьте установку и повторите попытку." } }, + "th": { "stringUnit": { "state": "translated", "value": "ไม่สามารถเริ่ม CLI ที่จำเป็นได้ โปรดตรวจสอบการติดตั้งแล้วลองอีกครั้ง" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Gerekli CLI başlatılamadı. Kurulumu kontrol edip tekrar deneyin." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Не вдалося запустити потрібний CLI. Перевірте встановлення та повторіть спробу." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法启动所需的 CLI。请检查安装并重试。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "無法啟動必要的 CLI。請檢查安裝後再試一次。" } } + } + }, + "cli.coderouter.error.notFound": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تعذر العثور على أداة سطر الأوامر المطلوبة. ثبّت الأمر ثم أعد المحاولة." } }, + "bs": { "stringUnit": { "state": "translated", "value": "Traženi CLI nije pronađen. Instalirajte naredbu i pokušajte ponovo." } }, + "da": { "stringUnit": { "state": "translated", "value": "Den nødvendige CLI blev ikke fundet. Installer kommandoen, og prøv igen." } }, + "de": { "stringUnit": { "state": "translated", "value": "Die erforderliche CLI wurde nicht gefunden. Installieren Sie den Befehl und versuchen Sie es erneut." } }, + "en": { "stringUnit": { "state": "translated", "value": "Required CLI not found. Install the command and retry." } }, + "es": { "stringUnit": { "state": "translated", "value": "No se encontró la CLI necesaria. Instala el comando y vuelve a intentarlo." } }, + "fr": { "stringUnit": { "state": "translated", "value": "La CLI requise est introuvable. Installez la commande, puis réessayez." } }, + "it": { "stringUnit": { "state": "translated", "value": "La CLI richiesta non è stata trovata. Installa il comando e riprova." } }, + "ja": { "stringUnit": { "state": "translated", "value": "必要な CLI が見つかりません。コマンドをインストールして、もう一度お試しください。" } }, + "km": { "stringUnit": { "state": "translated", "value": "រកមិនឃើញ CLI ដែលត្រូវការ។ សូមដំឡើងពាក្យបញ្ជា ហើយព្យាយាមម្ដងទៀត។" } }, + "ko": { "stringUnit": { "state": "translated", "value": "필요한 CLI를 찾을 수 없습니다. 명령을 설치한 후 다시 시도하세요." } }, + "nb": { "stringUnit": { "state": "translated", "value": "Fant ikke den nødvendige CLI-en. Installer kommandoen og prøv igjen." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Nie znaleziono wymaganej CLI. Zainstaluj polecenie i spróbuj ponownie." } }, + "pt-BR": { "stringUnit": { "state": "translated", "value": "A CLI necessária não foi encontrada. Instale o comando e tente novamente." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Не удалось найти необходимый CLI. Установите команду и повторите попытку." } }, + "th": { "stringUnit": { "state": "translated", "value": "ไม่พบ CLI ที่จำเป็น โปรดติดตั้งคำสั่งแล้วลองอีกครั้ง" } }, + "tr": { "stringUnit": { "state": "translated", "value": "Gerekli CLI bulunamadı. Komutu yükleyip tekrar deneyin." } }, + "uk": { "stringUnit": { "state": "translated", "value": "Не знайдено потрібний CLI. Установіть команду та повторіть спробу." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "未找到所需的 CLI。请安装该命令后重试。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "找不到必要的 CLI。請安裝該命令後再試一次。" } } + } } }, "version": "1.0" diff --git a/cmuxTests/CLIAuthAliasTests.swift b/cmuxTests/CLIAuthAliasTests.swift index 547da5518337..58386deabbea 100644 --- a/cmuxTests/CLIAuthAliasTests.swift +++ b/cmuxTests/CLIAuthAliasTests.swift @@ -1,5 +1,13 @@ import XCTest import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif extension CLINotifyProcessIntegrationRegressionTests { func testTopLevelLoginAliasesAuthLogin() throws { @@ -121,4 +129,430 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected logout alias to call auth.sign_out, saw \(state.commands)" ) } + +} + +@Suite("CodeRouter CLI aliases") +struct CLICoderouterAliasTests { + private struct ProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + @Test("forwards argv and inherited stdio, preferring coderouter") + func forwardsArgvAndStdioAndExitStatus() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-coderouter-alias-\(UUID().uuidString)", isDirectory: true) + let argsURL = root.appendingPathComponent("args.txt", isDirectory: false) + let stdinURL = root.appendingPathComponent("stdin.txt", isDirectory: false) + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try writeExecutable( + """ + #!/bin/sh + set -eu + : > "$CODEROUTER_ARGS_FILE" + for arg in "$@"; do + printf '<%s>\\n' "$arg" >> "$CODEROUTER_ARGS_FILE" + done + /bin/cat > "$CODEROUTER_STDIN_FILE" + printf 'coderouter stdout\\n' + printf 'coderouter stderr\\n' >&2 + exit 37 + """, + at: root.appendingPathComponent("coderouter", isDirectory: false) + ) + try writeExecutable( + """ + #!/bin/sh + printf 'the cr fallback must not win over coderouter\\n' >&2 + exit 99 + """, + at: root.appendingPathComponent("cr", isDirectory: false) + ) + + let result = runCLI( + cliPath: cliPath, + arguments: [ + "coderouter", + "add", + "--provider", + "codex go", + "--", + "echo; touch should-not-run", + ], + environment: [ + "PATH": root.path, + "CODEROUTER_ARGS_FILE": argsURL.path, + "CODEROUTER_STDIN_FILE": stdinURL.path, + "CMUX_SOCKET_PATH": makeSocketPath("missing"), + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: "interactive login input\n" + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 37, Comment(rawValue: result.stderr)) + #expect(result.stdout == "coderouter stdout\n") + #expect(result.stderr == "coderouter stderr\n") + #expect( + try String(contentsOf: argsURL, encoding: .utf8) + == """ + + <--provider> + + <--> + + """ + "\n" + ) + #expect( + try String(contentsOf: stdinURL, encoding: .utf8) + == "interactive login input\n" + ) + } + + @Test("the short alias still prefers coderouter when both names exist") + func crAliasPrefersCoderouter() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cr-preference-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try writeExecutable( + """ + #!/bin/sh + printf 'canonical coderouter\\n' + exit 41 + """, + at: root.appendingPathComponent("coderouter", isDirectory: false) + ) + try writeExecutable( + """ + #!/bin/sh + printf 'the cr executable was selected\\n' >&2 + exit 99 + """, + at: root.appendingPathComponent("cr", isDirectory: false) + ) + + let result = runCLI( + cliPath: cliPath, + arguments: ["cr", "--version"], + environment: [ + "PATH": root.path, + "CMUX_SOCKET_PATH": makeSocketPath("missing"), + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 41, Comment(rawValue: result.stderr)) + #expect(result.stdout == "canonical coderouter\n") + #expect(result.stderr.isEmpty) + } + + @Test("falls back to cr and preserves its arguments and exit status") + func crFallbackPreservesArgumentsAndExitStatus() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cr-alias-\(UUID().uuidString)", isDirectory: true) + let argsURL = root.appendingPathComponent("args.txt", isDirectory: false) + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try writeExecutable( + """ + #!/bin/sh + printf '<%s>\\n' "$@" > "$CR_ARGS_FILE" + printf 'cr fallback\\n' + exit 23 + """, + at: root.appendingPathComponent("cr", isDirectory: false) + ) + + let result = runCLI( + cliPath: cliPath, + arguments: ["cr", "login", "--device-auth"], + environment: [ + "PATH": root.path, + "CR_ARGS_FILE": argsURL.path, + "CMUX_SOCKET_PATH": makeSocketPath("missing"), + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 23, Comment(rawValue: result.stderr)) + #expect(result.stdout == "cr fallback\n") + #expect(result.stderr.isEmpty) + #expect( + try String(contentsOf: argsURL, encoding: .utf8) + == "\n<--device-auth>\n" + ) + } + + @Test("localizes the alias help entry") + func aliasHelpUsesRequestedLocalization() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let result = runCLI( + cliPath: cliPath, + arguments: ["--help"], + environment: [ + "AppleLanguages": "(ja)", + "AppleLocale": "ja_JP", + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect( + result.stdout.contains("インストール済み CodeRouter CLI のエイリアス"), + Comment(rawValue: result.stdout) + ) + #expect( + !result.stdout.contains("aliases for the installed CodeRouter CLI"), + Comment(rawValue: result.stdout) + ) + } + + @Test("does not leak cmux control environment to the child") + func childEnvironmentExcludesCmuxControlValues() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-coderouter-environment-\(UUID().uuidString)", isDirectory: true) + let environmentURL = root.appendingPathComponent("environment.txt", isDirectory: false) + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try writeExecutable( + """ + #!/bin/sh + /usr/bin/env | /usr/bin/sort > "$CODEROUTER_ENV_FILE" + printf 'environment captured\\n' + """, + at: root.appendingPathComponent("coderouter", isDirectory: false) + ) + + let result = runCLI( + cliPath: cliPath, + arguments: ["coderouter", "env"], + environment: [ + "PATH": root.path, + "CODEROUTER_ENV_FILE": environmentURL.path, + "CODEROUTER_TEST_MARKER": "preserved", + "CMUX_SOCKET": "/tmp/cmux-private.sock", + "CMUX_SOCKET_PATH": "/tmp/cmux-private-path.sock", + "CMUX_SOCKET_CAPABILITY": "capability-secret", + "CMUX_SOCKET_PASSWORD": "password-secret", + "CMUX_AUTH_CREDENTIALS_FILE": "/tmp/cmux-credentials", + "CMUX_WORKSPACE_ID": "workspace-secret", + "CMUX_SURFACE_ID": "surface-secret", + "CMUXD_UNIX_PATH": "/tmp/cmuxd-private.sock", + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "environment captured\n") + #expect(result.stderr.isEmpty) + let childEnvironment = try String(contentsOf: environmentURL, encoding: .utf8) + let childEnvironmentLines = childEnvironment.split(separator: "\n").map(String.init) + #expect( + !childEnvironmentLines.contains { line in + line.hasPrefix("CMUX_") || line.hasPrefix("CMUXD_") + }, + Comment(rawValue: childEnvironment) + ) + #expect(childEnvironmentLines.contains("CODEROUTER_TEST_MARKER=preserved")) + #expect(!childEnvironment.contains("capability-secret")) + #expect(!childEnvironment.contains("password-secret")) + #expect(!childEnvironment.contains("workspace-secret")) + #expect(!childEnvironment.contains("surface-secret")) + } + + @Test("keeps launch diagnostics internal") + func launchFailureDoesNotExposePathOrSystemError() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-coderouter-launch-failure-\(UUID().uuidString)", isDirectory: true) + let executableURL = root.appendingPathComponent("coderouter", isDirectory: false) + let debugLogURL = root.appendingPathComponent("debug.log", isDirectory: false) + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + // An executable file without a recognized format makes execve fail after + // PATH resolution, exercising the internal diagnostic path. + try writeExecutable("not an executable format\n", at: executableURL) + + let result = runCLI( + cliPath: cliPath, + arguments: ["coderouter", "launch"], + environment: [ + "PATH": root.path, + "CMUX_SOCKET_PATH": makeSocketPath("launch-failure"), + "CMUX_DEBUG_LOG": debugLogURL.path, + ] + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 127, Comment(rawValue: result.stderr)) + #expect(result.stdout.isEmpty) + #expect(result.stderr.contains("Could not start the required CLI")) + #expect(!result.stderr.contains(root.path)) + #expect(!result.stderr.contains("Exec format error")) + +#if DEBUG + let debugLog = try String(contentsOf: debugLogURL, encoding: .utf8) + #expect(debugLog.contains("cli.coderouter.exec_failed")) + #expect(debugLog.contains(executableURL.path)) + #expect(debugLog.contains("errno=")) +#endif + } + + @Test("reports an actionable error when neither executable exists") + func missingExecutableIsActionable() throws { + let cliPath = try BundledCLITestSupport.bundledCLIPath( + for: BundledCLILinkageTests.self + ) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-coderouter-missing-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeSocketPath("missing") + defer { try? fileManager.removeItem(at: root) } + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + + let result = runCLI( + cliPath: cliPath, + arguments: ["coderouter", "login"], + environment: [ + "PATH": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_SOCKET_CAPABILITY": "missing-capability", + "CMUX_SOCKET_PASSWORD": "missing-password", + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + ) + + #expect(!result.timedOut) + #expect(result.status == 127, Comment(rawValue: result.stderr)) + #expect(result.stdout.isEmpty) + #expect(result.stderr.contains("Required CLI not found")) + #expect(result.stderr.contains("Install the command")) + #expect(!result.stderr.contains("CodeRouter")) + #expect(!result.stderr.contains("coderouter")) + #expect(!result.stderr.contains("PATH")) + #expect(!result.stderr.contains(root.path)) + #expect(!result.stderr.contains(socketPath)) + #expect(!result.stderr.contains("missing-capability")) + #expect(!result.stderr.contains("missing-password")) + } + + private func runCLI( + cliPath: String, + arguments: [String], + environment: [String: String], + standardInput: String? = nil + ) -> ProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = arguments + var childEnvironment = ProcessInfo.processInfo.environment + for key in childEnvironment.keys where key.hasPrefix("CMUX_") || key.hasPrefix("CMUXD_") { + childEnvironment.removeValue(forKey: key) + } + childEnvironment.merge(environment) { _, newValue in newValue } + childEnvironment["AppleLanguages"] = childEnvironment["AppleLanguages"] ?? "(en)" + childEnvironment["AppleLocale"] = childEnvironment["AppleLocale"] ?? "en_US" + process.environment = childEnvironment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + let stdinPipe: Pipe? + if standardInput != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + process.standardInput = FileHandle.nullDevice + stdinPipe = nil + } + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + do { + try process.run() + } catch { + return ProcessResult( + status: 127, + stdout: "", + stderr: error.localizedDescription, + timedOut: false + ) + } + if let standardInput, let stdinPipe, + let data = standardInput.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + try? stdinPipe.fileHandleForWriting.close() + } + let timedOut: Bool + switch finished.wait(timeout: .now() + 5) { + case .success: + timedOut = false + case .timedOut: + timedOut = true + process.terminate() + if finished.wait(timeout: .now() + 1) == .timedOut { + Darwin.kill(process.processIdentifier, SIGKILL) + _ = finished.wait(timeout: .now() + 1) + } + } + + let stdout = String( + data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let stderr = String( + data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + return ProcessResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private func writeExecutable(_ contents: String, at url: URL) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: url.path + ) + } + + private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return "/tmp/cli-\(name.prefix(3))-\(shortID).sock" + } }