From 9aee75a398b7d218268d2419e31d26510f61f5f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 14 May 2026 04:46:46 -0700 Subject: [PATCH 1/2] Test legacy agent-hook aliases emit only {} JSON Stale `cmux -hook` entries in `~/.cursor/hooks.json` (and other agent hook configs) still invoke the legacy command names removed in commit 6beb3dbe1. The unknown-command path prints CLI usage to stdout and exits non-zero, which combines with `|| echo '{}'` to produce "usage text + {}", which Cursor agent rejects as invalid JSON and uses to block every shell execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmuxTests/CLILegacyHookAliasTests.swift | 87 +++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/cmuxTests/CLILegacyHookAliasTests.swift b/cmuxTests/CLILegacyHookAliasTests.swift index 1b8ecc7463c6..e29bb6b864d2 100644 --- a/cmuxTests/CLILegacyHookAliasTests.swift +++ b/cmuxTests/CLILegacyHookAliasTests.swift @@ -89,4 +89,91 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertFalse(result.stdout.contains("Usage:"), result.stdout) XCTAssertFalse(result.stderr.contains("Usage:"), result.stderr) } + + // Regression: legacy `cmux -hook ` aliases were removed + // in commit 6beb3dbe1 ("Namespace agent hook commands") but `~/.cursor/hooks.json` + // and other agent hook configs still point at them. The unknown-command path + // prints the full CLI usage to stdout and exits non-zero, which combines with + // the `|| echo '{}'` fallback in the installed hook command to produce + // garbage-followed-by-`{}`. Cursor agent rejects that as invalid JSON and + // blocks every shell command. The alias must succeed and emit exactly `{}\n`. + func testLegacyCursorHookAliasShellExecReturnsJSONWithoutHelp() throws { + try assertLegacyAgentHookAliasReturnsEmptyJSON( + agentName: "cursor", + subcommand: "shell-exec", + slug: "legacy-cursor", + stdin: #"{"command":"echo hello","cwd":"/tmp","hook_event_name":"beforeShellExecution"}"# + ) + } + + // Regression: same class of bug for gemini-hook. Confirms the alias dispatch + // generalizes across agents that lost their legacy `-hook` command. + func testLegacyGeminiHookAliasReturnsJSONWithoutHelp() throws { + try assertLegacyAgentHookAliasReturnsEmptyJSON( + agentName: "gemini", + subcommand: "session-start", + slug: "legacy-gemini", + stdin: #"{"hook_event_name":"SessionStart","session_id":"legacy-gemini"}"# + ) + } + + private func assertLegacyAgentHookAliasReturnsEmptyJSON( + agentName: String, + subcommand: String, + slug: String, + stdin: String + ) throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath(slug) + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(slug)-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") + ? self.malformedRequestResponse(raw: line) + : "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + if method == "surface.list" { return self.surfaceListResponse(id: id, surfaceId: surfaceId) } + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = workspaceId + environment["CMUX_SURFACE_ID"] = surfaceId + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["\(agentName)-hook", subcommand], + environment: environment, + standardInput: stdin, + timeout: 5 + ) + + wait(for: [serverHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, "stdout=\(result.stdout) stderr=\(result.stderr)") + XCTAssertEqual(result.stdout, "{}\n", "stderr=\(result.stderr)") + XCTAssertFalse(result.stdout.contains("Usage:"), result.stdout) + XCTAssertFalse(result.stderr.contains("Usage:"), result.stderr) + XCTAssertFalse(result.stdout.contains("Unknown command"), result.stdout) + XCTAssertFalse(result.stderr.contains("Unknown command"), result.stderr) + } } From f8a097ade65675d893700c675c330b4cafa901fb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 14 May 2026 04:57:13 -0700 Subject: [PATCH 2/2] Forward legacy `cmux -hook` commands to namespaced dispatcher Commit 6beb3dbe1 ("Namespace agent hook commands") renamed the per-agent hook commands from `cmux -hook ` to `cmux hooks ` and kept backwards-compat only for `codex-hook` and `feed-hook`. Every other agent (cursor, gemini, opencode, copilot, codebuddy, factory, qoder) lost its legacy alias. Stale entries in `~/./hooks.json` still invoke the old names. The unknown-command path prints the full CLI usage to stdout and exits non-zero, which combines with the `|| echo '{}'` fallback in the installed hook command to produce "usage text + {}". Cursor agent rejects that as invalid JSON and blocks every shell execution. Detect any `-hook` form for a registered agent and forward it to `runGenericAgentHook`, mirroring the existing codex-hook compat shim across all agents. Generalize `isLegacyCmuxOwnedHookCommand` and the hook markers to recognize the legacy form per agent so reinstall purges stale entries instead of layering new ones beside them. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLI/CMUXCLI+AgentHookDefinitions.swift | 43 +++++++++++++++++--------- CLI/cmux.swift | 28 ++++++++++++++--- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 43e9a35a82ab..0e357d29d98a 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -263,10 +263,11 @@ extension CMUXCLI { } private static func isLegacyCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef) -> Bool { - // Legacy cmux codex-hook and feed-hook commands only existed for Codex hooks. - guard def.name == "codex" else { - return false - } + // Legacy `cmux -hook` and `cmux feed-hook --source ` + // commands were removed in commit 6beb3dbe1 ("Namespace agent hook + // commands") for every agent that had them, not only Codex. Detect + // them across all agents so reinstall purges stale entries from + // user hook config files. let tokens = legacyCmuxCommandTokens(from: command, for: def) guard !tokens.isEmpty, URL(fileURLWithPath: String(tokens[0])).lastPathComponent == "cmux" @@ -274,7 +275,7 @@ extension CMUXCLI { return false } - if tokens.count >= 2, tokens[1] == "codex-hook" { + if tokens.count >= 2, tokens[1] == "\(def.name)-hook" { return true } if tokens.count >= 4, tokens[1] == "feed-hook", tokens[2] == "--source", tokens[3] == def.name { @@ -286,6 +287,26 @@ extension CMUXCLI { return false } + /// If `command` looks like a legacy `-hook` CLI command name + /// (e.g. `cursor-hook`, `gemini-hook`), returns the canonical agent + /// name. Used to forward legacy invocations to the current + /// `cmux hooks ` dispatcher so stale entries in + /// user hook config files keep returning valid JSON instead of + /// printing the CLI usage on unknown command. + static func legacyAgentNameFromHookCommand(_ command: String) -> String? { + let normalized = command.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalized.hasSuffix("-hook"), normalized.count > "-hook".count else { + return nil + } + let candidate = String(normalized.dropLast("-hook".count)) + if candidate == "claude" || candidate == "feed" { + // claude-hook and feed-hook have their own dispatcher paths and + // are not aliases of `hooks `. + return nil + } + return agentDef(named: candidate)?.name + } + private static func legacyCmuxCommandTokens(from command: String, for def: AgentHookDef) -> [Substring] { let guardedPrefix = "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && " let fallbackSuffix = " || echo '{}'" @@ -303,20 +324,12 @@ extension CMUXCLI { } static func hookMarkers(for def: AgentHookDef) -> [String] { - var markers = [def.hookMarker] - if def.name == "codex" { - markers.append("cmux codex-hook") - } - return markers + [def.hookMarker, "cmux \(def.name)-hook"] } /// Marker substrings used when removing / upgrading our own Feed bridge /// entries on reinstall or uninstall. static func feedHookMarkers(for def: AgentHookDef) -> [String] { - var markers = ["cmux hooks feed --source"] - if def.name == "codex" { - markers.append("cmux feed-hook --source") - } - return markers + ["cmux hooks feed --source", "cmux feed-hook --source"] } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9ba1a6fd33de..d55b0c36dcc5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2606,7 +2606,7 @@ struct CMUXCLI { } } if command == "setup-hooks" || command == "uninstall-hooks" { try runSetupHooks(uninstall: command == "uninstall-hooks"); return } // Backwards compatibility for old hook setup docs/scripts. - if (command == "codex-hook" || command == "feed-hook"), processEnv["CMUX_SURFACE_ID"]?.isEmpty != false, processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false, + if Self.legacyAgentNameFromHookCommand(command) != nil, processEnv["CMUX_SURFACE_ID"]?.isEmpty != false, processEnv["CMUX_WORKSPACE_ID"]?.isEmpty != false, !commandArgs.contains(where: { $0 == "--workspace" || $0 == "--surface" || $0.hasPrefix("--workspace=") || $0.hasPrefix("--surface=") }) { print("{}"); return } // Backwards compatibility for old installed hooks outside cmux terminals. if command == "hooks" { if try runHooksNoSocketCommand(commandArgs: commandArgs) { @@ -2712,7 +2712,7 @@ struct CMUXCLI { } } - let capturesSocketErrorsInsideCommand = ["claude-hook", "codex-hook", "feed-hook", "hooks"].contains(command) // Backwards compatibility aliases stay hidden from help. + let capturesSocketErrorsInsideCommand = ["claude-hook", "feed-hook", "hooks"].contains(command) || Self.legacyAgentNameFromHookCommand(command) != nil // Backwards compatibility aliases stay hidden from help. do { switch command { case "ping": @@ -3878,9 +3878,6 @@ struct CMUXCLI { captureSocketTransportError(telemetry: cliTelemetry, stage: "claude_hook_dispatch", error: error, client: client) throw error } - case "codex-hook": // Backwards compatibility for older installed Codex hooks. Hidden from help. - guard let codexDef = Self.agentDef(named: "codex") else { print("{}"); return } - try runGenericAgentHook(def: codexDef, commandArgs: commandArgs, client: client, telemetry: cliTelemetry) case "feed-hook": // Backwards compatibility for older installed Feed hooks. Hidden from help. try runFeedHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) case "hooks": @@ -3983,6 +3980,27 @@ struct CMUXCLI { try runMarkdownCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) default: + // Backwards compatibility for legacy `cmux -hook ...` commands + // (e.g. `cmux cursor-hook shell-exec`). The legacy names were removed + // in commit 6beb3dbe1 ("Namespace agent hook commands"), but stale + // entries in user hook config files keep invoking them. Without this + // shim the unknown-command path prints CLI usage to stdout and exits + // non-zero, which combines with the `|| echo '{}'` fallback in the + // installed hook command to yield "usage text + {}". Cursor agent + // rejects that as invalid JSON and blocks every shell execution. + if let agentName = Self.legacyAgentNameFromHookCommand(command), + let def = Self.agentDef(named: agentName) { + cliTelemetry.breadcrumb("\(agentName)-hook.dispatch.legacy") + do { + try runGenericAgentHook(def: def, commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("\(agentName)-hook.completed") + } catch { + cliTelemetry.breadcrumb("\(agentName)-hook.failure") + captureSocketTransportError(telemetry: cliTelemetry, stage: "\(agentName)_hook_dispatch", error: error, client: client) + throw error + } + break + } print(usage()) throw CLIError(message: "Unknown command: \(command)") }