From cf0e2268a86fb0bbbac8238744f9dc3ca340a1a9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:27:14 -0700 Subject: [PATCH 01/29] test: cover non-blocking Codex feed permissions --- .../CLIGenericHookPersistenceTests.swift | 17 ++++++- cmuxTests/FeedEventClassificationTests.swift | 9 ++-- tests/test_codex_feed_hooks.py | 44 +++++++++---------- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index a521c3f08dbc..94d009a09c5c 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -2915,11 +2915,12 @@ extension CLINotifyProcessIntegrationRegressionTests { .appendingPathComponent("cmux-session.json", isDirectory: false) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) - let allCommands = hooks.values + let codexHookEntries = hooks.values .compactMap { $0 as? [[String: Any]] } .flatMap { $0 } .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } + let allCommands = codexHookEntries .compactMap { $0["command"] as? String } XCTAssertFalse(allCommands.isEmpty) @@ -3139,6 +3140,20 @@ extension CLINotifyProcessIntegrationRegressionTests { 1, "Codex setup should collapse duplicate cmux-owned prompt hooks to one entry, saw \(allCommands)" ) + XCTAssertTrue( + codexHookEntries.contains { + ($0["command"] as? String)?.contains("hooks codex prompt-submit") == true + && ($0["timeout"] as? Int) == 5 + }, + "Codex lifecycle hooks must use Codex's second-based timeout field, saw \(codexHookEntries)" + ) + XCTAssertTrue( + codexHookEntries.contains { + ($0["command"] as? String)?.contains("hooks feed --source codex --event PermissionRequest") == true + && ($0["timeout"] as? Int) == 5 + }, + "Codex Feed hooks are telemetry and must use a short second-based timeout, saw \(codexHookEntries)" + ) } func testGrokHookInstallRejectsFileAtHooksDirectory() throws { diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 5515a3b62479..fbfb10beef02 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -104,14 +104,15 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "AskUserQuestion").actionable == true) } - /// Codex has a dedicated `PermissionRequest` feed event, so its - /// pre-tool events (`PreToolUse` and the Codex-specific - /// `beforeShellExecution`) are telemetry only. + /// Codex runs `PermissionRequest` hooks before its own approval reviewer, + /// so Feed must keep both pre-tool events and permission requests as + /// telemetry. Otherwise "Approve for me" gets bypassed by cmux's Feed UI. @Test func codexPreToolUseIsTelemetry() { #expect(classify("codex", "PreToolUse", tool: "shell").actionable == false) #expect(classify("codex", "beforeShellExecution", tool: "shell").actionable == false) #expect(classify("codex", "beforeShellExecution", tool: "shell").name == "PreToolUse") - #expect(classify("codex", "PermissionRequest", tool: "shell").actionable == true) + #expect(classify("codex", "PermissionRequest", tool: "shell").name == "PreToolUse") + #expect(classify("codex", "PermissionRequest", tool: "shell").actionable == false) } /// Unknown source + unknown event is safe by default. diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 450b57418c9c..9d0b3361509c 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -708,6 +708,12 @@ def test_install_adds_codex_permission_request_hook(cli_path: str, root: Path) - hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) hook_groups = hooks.get("hooks", {}) + for event_name in ["SessionStart", "UserPromptSubmit", "Stop"]: + groups = hook_groups.get(event_name) + if not groups: + raise AssertionError(f"missing {event_name} hook group: {hooks!r}") + if groups[-1]["hooks"][0].get("timeout") != 5: + raise AssertionError(f"wrong {event_name} timeout: {groups[-1]!r}") for event_name in ["PreToolUse", "PermissionRequest"]: groups = hook_groups.get(event_name) if not groups: @@ -715,7 +721,7 @@ def test_install_adds_codex_permission_request_hook(cli_path: str, root: Path) - command = groups[-1]["hooks"][0]["command"] if command != cmux_codex_feed_command(event_name): raise AssertionError(f"wrong {event_name} feed command: {command!r}") - if groups[-1]["hooks"][0].get("timeout") != 120_000: + if groups[-1]["hooks"][0].get("timeout") != 5: raise AssertionError(f"wrong {event_name} timeout: {groups[-1]!r}") config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") @@ -1962,7 +1968,7 @@ def test_install_codex_hooks_preserves_config_when_toml_read_fails(cli_path: str ) -def test_permission_reply_uses_codex_permission_request_schema(cli_path: str, root: Path) -> None: +def test_codex_permission_request_is_nonblocking_telemetry(cli_path: str, root: Path) -> None: socket_path = root / "cmux.sock" payload = { "session_id": "codex-session", @@ -1979,27 +1985,17 @@ def test_permission_reply_uses_codex_permission_request_schema(cli_path: str, ro payload, {"kind": "permission", "mode": "once"}, ) - assert_permission_output(stdout, "allow") + if stdout != {}: + raise AssertionError(f"Codex PermissionRequest telemetry should not emit a decision: {stdout!r}") params = frame["params"] - if params.get("wait_timeout_seconds") != 120: - raise AssertionError(f"PermissionRequest should block for Feed reply: {frame!r}") + if params.get("wait_timeout_seconds") != 0: + raise AssertionError(f"Codex PermissionRequest should not wait for Feed reply: {frame!r}") event = params["event"] - if event.get("hook_event_name") != "PermissionRequest" or event.get("_source") != "codex": + if event.get("hook_event_name") != "PreToolUse" or event.get("_source") != "codex": raise AssertionError(f"wrong feed event: {event!r}") - stdout, _ = run_feed_hook( - cli_path, - root / "cmux-deny.sock", - payload, - {"kind": "permission", "mode": "deny"}, - ) - assert_permission_output(stdout, "deny") - message = stdout["hookSpecificOutput"]["decision"].get("message", "") - if "denied" not in message: - raise AssertionError(f"deny output should include a message: {stdout!r}") - -def test_codex_persistent_permission_modes_degrade_to_once(cli_path: str, root: Path) -> None: +def test_codex_permission_decisions_do_not_block_approval_reviewer(cli_path: str, root: Path) -> None: payload = { "session_id": "codex-session", "turn_id": "turn-persistent", @@ -2009,15 +2005,15 @@ def test_codex_persistent_permission_modes_degrade_to_once(cli_path: str, root: "tool_input": {"command": "printf hi"}, } - for mode in ["always", "all", "bypass"]: + for mode in ["once", "always", "all", "bypass", "deny"]: stdout, _ = run_feed_hook( cli_path, root / f"cmux-{mode}.sock", payload, {"kind": "permission", "mode": mode}, ) - assert_permission_output(stdout, "allow") - assert_codex_allow_has_no_persistent_fields(stdout) + if stdout != {}: + raise AssertionError(f"Codex PermissionRequest must not answer {mode}: {stdout!r}") def test_codex_pre_tool_use_is_telemetry_not_actionable(cli_path: str, root: Path) -> None: @@ -2107,15 +2103,15 @@ def main() -> int: test_install_surfaces_invalid_codex_config_encoding(cli_path, root) test_uninstall_surfaces_invalid_codex_config_encoding(cli_path, root) test_install_codex_hooks_preserves_config_when_toml_read_fails(cli_path, root) - test_permission_reply_uses_codex_permission_request_schema(cli_path, root) - test_codex_persistent_permission_modes_degrade_to_once(cli_path, root) + test_codex_permission_request_is_nonblocking_telemetry(cli_path, root) + test_codex_permission_decisions_do_not_block_approval_reviewer(cli_path, root) test_codex_pre_tool_use_is_telemetry_not_actionable(cli_path, root) test_claude_subagent_stop_stays_distinct_feed_telemetry(cli_path, root) except Exception as exc: print(f"FAIL: {exc}") return 1 - print("PASS: Codex Feed hooks use native permission approvals") + print("PASS: Codex Feed hooks leave Codex approvals non-blocking") return 0 From 970950f280ddfab1a5b0d9c1d679cd48a0eb217e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:27:19 -0700 Subject: [PATCH 02/29] fix: keep Codex permission hooks non-blocking --- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- CLI/FeedEventClassifier.swift | 5 ++++- CLI/cmux.swift | 29 ++++++++++++++++++++------ docs/agent-hooks.md | 2 +- docs/feed.md | 6 +++--- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 71b5b7ff1099..e94565309c16 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -53,7 +53,7 @@ extension CMUXCLI { enum HookFormat { case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1} - case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout + case nested(timeoutMs: Int) // Nested type/command/timeout hooks; timeout unit is agent-specific. case kiroAgentJSON(timeoutMs: Int) // ~/.kiro/agents/*.json flat command entries with timeout_ms case antigravityJSON(timeoutSeconds: Int) // ~/.gemini/config/hooks.json named hook groups case rovoDevYAML diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 37fe256329bc..7f6de0613f6d 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -169,7 +169,10 @@ struct FeedEventClassifier { "Notification": .statusNotification, ], "codex": [ - "PermissionRequest": .approvalRequest, + // Codex runs PermissionRequest hooks before its own approval + // reviewer. Treat this as telemetry so "Approve for me" can still + // use Codex's auto-review path instead of blocking on cmux Feed. + "PermissionRequest": .toolStart, "PreToolUse": .toolStart, "beforeShellExecution": .toolStart, "PostToolUse": .toolEnd, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1367bb0b3614..16b175e0c1bf 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25257,13 +25257,13 @@ struct CMUXCLI { break } } - // Layer in Feed bridge entries with a long timeout so blocking - // user decisions don't trip the agent's default per-event timeout. - // Most nested agents use milliseconds; Grok's and Antigravity's - // current hook schemas use seconds, so normalize before writing. - let feedTimeoutMs = 120_000 + // Layer in Feed bridge entries. Blocking approval bridges get a long + // timeout; Codex telemetry stays short so it never delays Codex's own + // approval reviewer. Most nested agents use milliseconds. Codex, Grok, + // and Antigravity hook schemas use seconds, so normalize before writing. for agentEvent in def.feedHookEvents { let feedCmd = feedHookCommand(for: def, agentEvent: agentEvent) + let feedTimeoutMs = feedHookTimeoutMs(for: def, agentEvent: agentEvent) switch def.format { case .flat: var entries = result[agentEvent] as? [[String: Any]] ?? [] @@ -25299,10 +25299,17 @@ struct CMUXCLI { } private func nestedHookTimeout(_ timeoutMs: Int, for def: AgentHookDef) -> Int { - guard def.name == "grok" else { return timeoutMs } + guard def.name == "codex" || def.name == "grok" else { return timeoutMs } return Self.timeoutSecondsFromMilliseconds(timeoutMs) } + private func feedHookTimeoutMs(for def: AgentHookDef, agentEvent _: String) -> Int { + if def.name == "codex" { + return 5_000 + } + return 120_000 + } + private static func timeoutSecondsFromMilliseconds(_ timeoutMs: Int) -> Int { let positiveTimeoutMs = max(timeoutMs, 1) return ((positiveTimeoutMs - 1) / 1000) + 1 @@ -26973,6 +26980,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { for event in def.events { guard let eventLabel = codexHookEventLabel(event.agentEvent) else { continue } + insertHashes( + eventLabel: eventLabel, + command: Self.hookCommandString(for: def, event: event), + timeouts: [5_000, 600] + ) insertHashes( eventLabel: eventLabel, command: "cmux codex-hook \(event.cmuxSubcommand)", @@ -26982,6 +26994,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { for agentEvent in def.feedHookEvents { guard let eventLabel = codexHookEventLabel(agentEvent) else { continue } + insertHashes( + eventLabel: eventLabel, + command: Self.feedHookCommandString(for: def, agentEvent: agentEvent), + timeouts: [120_000, 120, 600] + ) insertHashes( eventLabel: eventLabel, command: "cmux feed-hook --source \(def.name) --event \(agentEvent)", diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index ca6b5f9f8367..60d9010a5ead 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -18,7 +18,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Agent | Binary checked | Installed file | Session restore | Feed bridge | | --- | --- | --- | --- | --- | | Claude Code | `claude` through wrapper | wrapper-injected settings | `claude --resume ` | PermissionRequest | -| Codex | `codex` | `~/.codex/hooks.json`, `~/.codex/config.toml` | `codex resume ` | PreToolUse, PermissionRequest | +| Codex | `codex` | `~/.codex/hooks.json`, `~/.codex/config.toml` | `codex resume ` | PreToolUse, PermissionRequest telemetry | | Grok | `grok` | `~/.grok/hooks/cmux-session.json` | `grok -r ` | PreToolUse | | OpenCode | `opencode` | `~/.config/opencode/plugins/cmux-session.js`, `~/.config/opencode/plugins/cmux-feed.js` | `opencode --session ` | plugin event bus | | Pi | `pi` | `~/.pi/agent/extensions/cmux-session.ts` | `pi --session ` | none | diff --git a/docs/feed.md b/docs/feed.md index fe5175eaa9e1..7a4744fdd36d 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -75,7 +75,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | Agent | Config | Feed trigger | |--------------|-------------------------------------------|--------------------------| | Claude Code | wrapper-injected | PermissionRequest | -| Codex | `~/.codex/hooks.json` | PermissionRequest | +| Codex | `~/.codex/hooks.json` | PreToolUse / PermissionRequest telemetry | | Grok | `~/.grok/hooks/cmux-session.json` | PreToolUse | | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | @@ -128,13 +128,13 @@ For Claude Code, the cmux wrapper launches Claude with `--allow-dangerously-skip For Claude Code, AskUserQuestion is answered by allowing the PermissionRequest with an updated tool input containing the selected answers. Other agents use their native question reply shape where available. -Codex's `request_user_input` and `update_plan` currently surface through its app-server request/notification path, not through command hooks. A stock `codex` TUI running in a cmux terminal keeps those frames inside Codex's in-process app-server client, so its plan-mode questions still fall back to Codex's own TUI. cmux can route Codex permission approvals through `PermissionRequest`; showing Codex plan questions in Feed would require launching Codex against a shared standalone app server and adding a Codex app-server Feed adapter, or upstream Codex hook coverage for those frames. +Codex's `request_user_input`, `update_plan`, and approval prompts currently stay in Codex's own TUI/app-server path. cmux records Codex `PreToolUse` and `PermissionRequest` hooks as non-blocking telemetry only, because Codex runs `PermissionRequest` hooks before its `Approve for me` auto-review path. Blocking in cmux Feed would make Codex ask for Feed approval before its own reviewer can decide. Showing or answering Codex prompts in Feed would require launching Codex against a shared standalone app server and adding a Codex app-server Feed adapter, or upstream Codex hook coverage after approval review. ## Timeout behavior Feed is advisory, not blocking. The hook waits at most 120 seconds for a user decision. On timeout the bridge emits `{}` (no decision) and the agent falls through to its own in-TUI prompt. This matches Vibe Island's "soft wait" model, it never freezes a workflow forever. -Per-event timeout inside agent hook configs is raised to roughly 120 to 125 seconds for Feed bridge entries (Claude uses 125 seconds for PermissionRequest), so a user taking 30 seconds to approve something does not trip default 5 000 ms hook timeouts. +Per-event timeout inside agent hook configs is raised to roughly 120 to 125 seconds for blocking Feed bridge entries (Claude uses 125 seconds for PermissionRequest), so a user taking 30 seconds to approve something does not trip default 5 000 ms hook timeouts. Codex Feed hooks stay non-blocking and use a short timeout because Codex owns its own approval UI. ## Storage From dc3f0c99638a2eeec29183c59867d6d4c270eb27 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:32:56 -0700 Subject: [PATCH 03/29] feat: bridge Codex app-server approvals to Feed --- CLI/cmux.swift | 275 ++++++++++++++++-- Sources/Feed/FeedPermissionActionPolicy.swift | 2 +- cmuxTests/FeedCoordinatorTests.swift | 78 ++++- docs/feed.md | 4 +- 4 files changed, 336 insertions(+), 23 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 16b175e0c1bf..1325432a7b73 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18027,6 +18027,13 @@ struct CMUXCLI { try sendObject(["method": "initialized"], timeout: responseTimeout) } + func respond(requestId: Any, result: [String: Any], timeout: TimeInterval = 10) throws { + try sendObject([ + "id": requestId, + "result": result + ], timeout: timeout) + } + func request( method: String, params: [String: Any]? = nil, @@ -18175,6 +18182,7 @@ struct CMUXCLI { private let readinessLock = NSLock() private let stateLock = NSLock() private var lastAgentSurfaceId: String? + private var subscribedThreadIds = Set() init( appServerURL: String, @@ -18222,30 +18230,19 @@ struct CMUXCLI { method: "thread/loaded/list", params: ["limit": 200], notificationHandler: { [weak self] message in - try self?.handleNotification(message) + try self?.handleAppServerMessage( + message, + connection: connection, + allowThreadSubscribe: false + ) } ) let threadIds = loaded["data"] as? [String] ?? [] for threadId in threadIds { - let read: [String: Any] do { - read = try connection.request( - method: "thread/read", - params: [ - "threadId": threadId, - "includeTurns": false - ], - notificationHandler: { [weak self] message in - try self?.handleNotification(message) - } - ) + try subscribeToThreadIfNeeded(threadId, connection: connection) } catch { - fputs("cmux codex-teams watcher skipped unreadable thread \(threadId): \(error)\n", stderr) - continue - } - if let threadObject = read["thread"] as? [String: Any], - let thread = CMUXCLI.codexTeamsThread(from: threadObject) { - try observeThreadSafely(thread) + fputs("cmux codex-teams watcher skipped thread \(threadId): \(error)\n", stderr) } } } @@ -18253,12 +18250,20 @@ struct CMUXCLI { private func listenForNotifications(connection: CodexTeamsAppServerConnection) throws { while true { let message = try connection.receiveObject() - try handleNotification(message) + try handleAppServerMessage(message, connection: connection) } } - private func handleNotification(_ message: [String: Any]) throws { + private func handleAppServerMessage( + _ message: [String: Any], + connection: CodexTeamsAppServerConnection, + allowThreadSubscribe: Bool = true + ) throws { guard let method = message["method"] as? String else { return } + if let requestId = message["id"], + try handleApprovalRequest(message, method: method, requestId: requestId, connection: connection) { + return + } guard method.hasPrefix("thread/"), let params = message["params"] as? [String: Any], let threadObject = params["thread"] as? [String: Any], @@ -18266,6 +18271,88 @@ struct CMUXCLI { return } try observeThreadSafely(thread) + if allowThreadSubscribe { + do { + try subscribeToThreadIfNeeded(thread.id, connection: connection) + } catch { + fputs("cmux codex-teams watcher skipped thread \(thread.id): \(error)\n", stderr) + } + } + } + + private func subscribeToThreadIfNeeded( + _ threadId: String, + connection: CodexTeamsAppServerConnection + ) throws { + stateLock.lock() + let inserted = subscribedThreadIds.insert(threadId).inserted + stateLock.unlock() + guard inserted else { return } + + do { + let response = try connection.request( + method: "thread/resume", + params: [ + "threadId": threadId, + "excludeTurns": true + ], + notificationHandler: { [weak self] message in + try self?.handleAppServerMessage( + message, + connection: connection, + allowThreadSubscribe: false + ) + } + ) + if let threadObject = response["thread"] as? [String: Any], + let thread = CMUXCLI.codexTeamsThread(from: threadObject) { + try observeThreadSafely(thread) + } + } catch { + stateLock.lock() + subscribedThreadIds.remove(threadId) + stateLock.unlock() + throw error + } + } + + private func handleApprovalRequest( + _ message: [String: Any], + method: String, + requestId: Any, + connection: CodexTeamsAppServerConnection + ) throws -> Bool { + guard method == "item/commandExecution/requestApproval" + || method == "item/fileChange/requestApproval" + else { return false } + guard let params = message["params"] as? [String: Any] else { return true } + let feedEvent = CMUXCLI.codexTeamsFeedEvent( + method: method, + requestId: requestId, + params: params, + workspaceId: workspaceId + ) + fputs("cmux codex-teams watcher forwarding approval \(method) request \(CMUXCLI.requestIdString(requestId)) to Feed\n", stderr) + let response = try pushCodexApprovalToFeed(event: feedEvent) + guard let decision = CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: response) else { + return true + } + guard let result = CMUXCLI.codexTeamsAppServerApprovalResponse( + method: method, + params: params, + mode: decision + ) else { + return true + } + try connection.respond(requestId: requestId, result: result) + return true + } + + private func pushCodexApprovalToFeed(event: [String: Any]) throws -> [String: Any] { + try socketClient.sendV2(method: "feed.push", params: [ + "event": event, + "wait_timeout_seconds": 120 + ], responseTimeout: 125) } private func observeThreadSafely(_ thread: CodexTeamsThread) throws { @@ -18471,6 +18558,154 @@ struct CMUXCLI { } } + static func codexTeamsFeedEvent( + method: String, + requestId: Any, + params: [String: Any], + workspaceId: String + ) -> [String: Any] { + let threadId = stringValue(in: params, keys: ["threadId", "thread_id"]) + ?? stringValue(in: params, keys: ["threadID", "thread_id"]) + ?? "unknown" + let turnId = stringValue(in: params, keys: ["turnId", "turn_id"]) + let itemId = stringValue(in: params, keys: ["approvalId", "approval_id", "itemId", "item_id"]) + ?? requestIdString(requestId) + let cwd = stringValue(in: params, keys: ["cwd"]) + let reason = stringValue(in: params, keys: ["reason"]) + let command = stringValue(in: params, keys: ["command"]) + let toolName = method == "item/fileChange/requestApproval" ? "Write" : "Bash" + var toolInput: [String: Any] = [ + "app_server_method": method, + "request_id": requestIdString(requestId), + "item_id": itemId + ] + if let turnId { toolInput["turn_id"] = turnId } + if let reason { toolInput["reason"] = reason } + if let command { toolInput["command"] = command } + if let cwd { toolInput["cwd"] = cwd } + if let approvalId = stringValue(in: params, keys: ["approvalId", "approval_id"]) { + toolInput["approval_id"] = approvalId + } + if let grantRoot = params["grantRoot"] ?? params["grant_root"] { + toolInput["grant_root"] = grantRoot + } + if let available = params["availableDecisions"] ?? params["available_decisions"] { + toolInput["available_decisions"] = codexTeamsDecisionNames(available) + } + + var context: [String: Any] = [ + "permissionMode": "codex app-server" + ] + if let reason { + context["assistantPreamble"] = reason + } + if let command { + context["toolSummary"] = command + } + + var event: [String: Any] = [ + "session_id": "codex-\(threadId)", + "hook_event_name": "PermissionRequest", + "_source": "codex", + "workspace_id": workspaceId, + "tool_name": toolName, + "tool_input": toolInput, + "context": context, + "_opencode_request_id": "codex-app-server-\(itemId)" + ] + if let cwd { event["cwd"] = cwd } + return event + } + + static func codexTeamsPermissionMode(fromFeedPushResponse response: [String: Any]) -> String? { + guard (response["status"] as? String) == "resolved", + let decision = response["decision"] as? [String: Any], + (decision["kind"] as? String) == "permission", + let mode = decision["mode"] as? String + else { return nil } + return mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + static func codexTeamsAppServerApprovalResponse( + method: String, + params: [String: Any], + mode: String + ) -> [String: Any]? { + switch method { + case "item/commandExecution/requestApproval": + return ["decision": codexTeamsCommandApprovalDecision(params: params, mode: mode)] + case "item/fileChange/requestApproval": + return ["decision": codexTeamsFileChangeApprovalDecision(params: params, mode: mode)] + default: + return nil + } + } + + static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> String { + if mode == "deny" { return "decline" } + if codexTeamsModeRequestsPersistentApproval(mode), + codexTeamsAvailableDecisions(params).contains("acceptForSession") { + return "acceptForSession" + } + return "accept" + } + + static func codexTeamsFileChangeApprovalDecision(params: [String: Any], mode: String) -> String { + if mode == "deny" { return "decline" } + if codexTeamsModeRequestsPersistentApproval(mode), + codexTeamsAvailableDecisions(params).contains("acceptForSession") { + return "acceptForSession" + } + return "accept" + } + + static func codexTeamsModeRequestsPersistentApproval(_ mode: String) -> Bool { + mode == "always" || mode == "all" || mode == "bypass" + } + + static func codexTeamsAvailableDecisions(_ params: [String: Any]) -> Set { + guard let raw = params["availableDecisions"] ?? params["available_decisions"] else { + return [] + } + return Set(codexTeamsDecisionNames(raw)) + } + + static func codexTeamsDecisionNames(_ raw: Any) -> [String] { + let values = raw as? [Any] ?? [] + return values.compactMap { value in + if let string = value as? String { + return string + } + if let object = value as? [String: Any], + let key = object.keys.first { + return key + } + return nil + } + } + + static func requestIdString(_ requestId: Any) -> String { + if let string = requestId as? String { + return string + } + if let number = requestId as? NSNumber { + return number.stringValue + } + return String(describing: requestId) + } + + static func stringValue(in object: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = object[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } else if let value = object[key] as? NSNumber { + return value.stringValue + } + } + return nil + } + private static func codexTeamsThreadCanResume(appServerURL: String, threadId: String) -> Bool { guard let url = URL(string: appServerURL) else { return false diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index ea7be75a2e54..215149227a9c 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -2,7 +2,7 @@ import CMUXWorkstream enum FeedPermissionActionPolicy { static func supportsPersistentPermissionModes(source: WorkstreamSource) -> Bool { - source != .codex && source != .hermesAgent + source != .hermesAgent } static func supportsBypassPermissions(source: WorkstreamSource) -> Bool { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ab725ce7e314..ac63550af0de 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -12,7 +12,7 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) - XCTAssertFalse(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) + XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .codex)) XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) @@ -22,6 +22,82 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) } + func testCodexAppServerApprovalBuildsActionableFeedEvent() throws { + let event = CMUXCLI.codexTeamsFeedEvent( + method: "item/commandExecution/requestApproval", + requestId: 41, + params: [ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "call-1", + "approvalId": "approval-1", + "command": "touch /tmp/cmux-security-review", + "cwd": "/tmp/project", + "reason": "requires approval", + "availableDecisions": ["accept", "acceptForSession", "decline"] + ], + workspaceId: "workspace-1" + ) + + XCTAssertEqual(event["session_id"] as? String, "codex-thread-1") + XCTAssertEqual(event["hook_event_name"] as? String, "PermissionRequest") + XCTAssertEqual(event["_source"] as? String, "codex") + XCTAssertEqual(event["workspace_id"] as? String, "workspace-1") + XCTAssertEqual(event["_opencode_request_id"] as? String, "codex-app-server-approval-1") + XCTAssertEqual(event["tool_name"] as? String, "Bash") + XCTAssertEqual(event["cwd"] as? String, "/tmp/project") + + let toolInput = try XCTUnwrap(event["tool_input"] as? [String: Any]) + XCTAssertEqual(toolInput["app_server_method"] as? String, "item/commandExecution/requestApproval") + XCTAssertEqual(toolInput["request_id"] as? String, "41") + XCTAssertEqual(toolInput["item_id"] as? String, "approval-1") + XCTAssertEqual(toolInput["turn_id"] as? String, "turn-1") + XCTAssertEqual(toolInput["command"] as? String, "touch /tmp/cmux-security-review") + + let context = try XCTUnwrap(event["context"] as? [String: Any]) + XCTAssertEqual(context["permissionMode"] as? String, "codex app-server") + XCTAssertEqual(context["assistantPreamble"] as? String, "requires approval") + } + + func testCodexAppServerApprovalResponseFollowsFeedDecision() { + let params: [String: Any] = [ + "availableDecisions": ["accept", "acceptForSession", "decline"] + ] + + XCTAssertEqual( + CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: [ + "status": "resolved", + "decision": ["kind": "permission", "mode": "always"] + ]), + "always" + ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: params, + mode: "always" + )?["decision"] as? String, + "acceptForSession" + ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/fileChange/requestApproval", + params: [:], + mode: "once" + )?["decision"] as? String, + "accept" + ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: params, + mode: "deny" + )?["decision"] as? String, + "decline" + ) + XCTAssertNil(CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: ["status": "timed_out"])) + } + func testBlockingIngestExpiresItemWhenHookTimesOut() async { await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) diff --git a/docs/feed.md b/docs/feed.md index 7a4744fdd36d..43d77f1a87c2 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -128,7 +128,9 @@ For Claude Code, the cmux wrapper launches Claude with `--allow-dangerously-skip For Claude Code, AskUserQuestion is answered by allowing the PermissionRequest with an updated tool input containing the selected answers. Other agents use their native question reply shape where available. -Codex's `request_user_input`, `update_plan`, and approval prompts currently stay in Codex's own TUI/app-server path. cmux records Codex `PreToolUse` and `PermissionRequest` hooks as non-blocking telemetry only, because Codex runs `PermissionRequest` hooks before its `Approve for me` auto-review path. Blocking in cmux Feed would make Codex ask for Feed approval before its own reviewer can decide. Showing or answering Codex prompts in Feed would require launching Codex against a shared standalone app server and adding a Codex app-server Feed adapter, or upstream Codex hook coverage after approval review. +Codex's hook-level `request_user_input`, `update_plan`, and approval prompts stay in Codex's own TUI/app-server path. cmux records Codex `PreToolUse` and `PermissionRequest` hooks as non-blocking telemetry only, because Codex runs `PermissionRequest` hooks before its `Approve for me` auto-review path. Blocking in hook mode would make Codex ask for Feed approval before its own reviewer can decide. + +When Codex is launched through `cmux codex-teams`, cmux owns the private Codex app-server connection. The Codex Teams watcher listens for app-server command and file-change approval requests, which happen after Codex has decided that user approval is needed, and bridges those requests to Feed as actionable permission cards. A Feed click responds to the app-server request. If Feed times out or no decision is returned, cmux does not send a denial so Codex's native TUI approval can still answer the request. ## Timeout behavior From d57d329f53329081dec21ed3056821ce16d8330d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:33:48 -0700 Subject: [PATCH 04/29] Validate Codex Teams launch cwd --- CLI/cmux.swift | 56 ++++++++++++++++++++++++++++ cmuxTests/FeedCoordinatorTests.swift | 46 +++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1325432a7b73..893fdf207a53 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18891,6 +18891,10 @@ struct CMUXCLI { throw CLIError(message: "cmux codex-teams must be started from a cmux terminal surface") } let rootWorkspaceId = rootIdentity.workspaceId ?? focusedContext.workspaceId + try Self.validateCodexTeamsWorkingDirectory( + commandArgs: commandArgs, + baseDirectory: launcherEnvironment["PWD"] ?? FileManager.default.currentDirectoryPath + ) let codexExecutablePath = resolveCodexExecutable(searchPath: launcherEnvironment["PATH"]) let codexExecutableForShell = codexExecutablePath ?? "codex" @@ -19087,6 +19091,58 @@ struct CMUXCLI { .joined(separator: ":") ?? "" } + static func codexTeamsResolvedWorkingDirectory( + commandArgs: [String], + baseDirectory: String + ) -> String? { + let valueOptions: Set = ["-C", "--cd", "--cwd"] + let optionPrefixes = valueOptions.map { "\($0)=" } + var index = 0 + var requested: String? + while index < commandArgs.count { + let arg = commandArgs[index] + if arg == "--" { break } + if valueOptions.contains(arg), index + 1 < commandArgs.count { + requested = commandArgs[index + 1] + index += 2 + continue + } + if let prefix = optionPrefixes.first(where: { arg.hasPrefix($0) }) { + requested = String(arg.dropFirst(prefix.count)) + } + index += 1 + } + guard let requested = requested?.trimmingCharacters(in: .whitespacesAndNewlines), + !requested.isEmpty else { + return nil + } + let expanded = (requested as NSString).expandingTildeInPath + if expanded.hasPrefix("/") { + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + return URL( + fileURLWithPath: expanded, + relativeTo: URL(fileURLWithPath: baseDirectory, isDirectory: true) + ).standardizedFileURL.path + } + + static func validateCodexTeamsWorkingDirectory( + commandArgs: [String], + baseDirectory: String + ) throws { + guard let cwd = codexTeamsResolvedWorkingDirectory( + commandArgs: commandArgs, + baseDirectory: baseDirectory + ) else { + return + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: cwd, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw CLIError(message: "cmux codex-teams cwd does not exist: \(cwd)") + } + } + private func codexTeamsAppendPathEntry( _ entry: String, entries: inout [String], diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ac63550af0de..cf840dff848d 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -8,6 +8,52 @@ import CMUXWorkstream #endif final class FeedCoordinatorTests: XCTestCase { + func testCodexTeamsResolvesExplicitWorkingDirectoryFlags() throws { + let base = "/tmp/cmux-base" + + XCTAssertEqual( + CMUXCLI.codexTeamsResolvedWorkingDirectory( + commandArgs: ["-C", "child", "prompt"], + baseDirectory: base + ), + "/tmp/cmux-base/child" + ) + XCTAssertEqual( + CMUXCLI.codexTeamsResolvedWorkingDirectory( + commandArgs: ["--cwd=/tmp/cmux-review", "--cd", "/tmp/cmux-final"], + baseDirectory: base + ), + "/tmp/cmux-final" + ) + XCTAssertNil( + CMUXCLI.codexTeamsResolvedWorkingDirectory( + commandArgs: ["--", "-C", "/tmp/inside-prompt"], + baseDirectory: base + ) + ) + } + + func testCodexTeamsValidatesExplicitWorkingDirectoryExists() throws { + let existing = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-teams-cwd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: existing) } + + XCTAssertNoThrow( + try CMUXCLI.validateCodexTeamsWorkingDirectory( + commandArgs: ["-C", existing.path], + baseDirectory: "/tmp" + ) + ) + + XCTAssertThrowsError( + try CMUXCLI.validateCodexTeamsWorkingDirectory( + commandArgs: ["-C", existing.appendingPathComponent("missing").path], + baseDirectory: "/tmp" + ) + ) + } + func testClaudePermissionActionPolicyKeepsBypassUserOwned() { XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) From 7ed2a21e7149ed5f637821ffc2b669dce30f3361 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:55:48 -0700 Subject: [PATCH 05/29] Address Codex Teams approval review findings --- CLI/cmux.swift | 150 +++++++++++++++++++++++++-- cmuxTests/FeedCoordinatorTests.swift | 89 +++++++++++++++- 2 files changed, 230 insertions(+), 9 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 893fdf207a53..dc307dec3d63 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17947,6 +17947,12 @@ struct CMUXCLI { private static let codexTeamsMaxAutoDepth = 2 private static let codexTeamsReconcileInterval: TimeInterval = 1 + private static let codexTeamsMaxCachedApprovalItems = 500 + static let codexTeamsApprovalMethods: Set = [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval" + ] private static let codexTeamsProbeClientName = "codex_app_server_daemon" private static let codexTeamsWatcherClientName = "cmux-codex-teams" private static let codexTeamsClientVersion = "0.1.0" @@ -18183,6 +18189,8 @@ struct CMUXCLI { private let stateLock = NSLock() private var lastAgentSurfaceId: String? private var subscribedThreadIds = Set() + private var approvalItemById: [String: [String: Any]] = [:] + private var approvalItemOrder: [String] = [] init( appServerURL: String, @@ -18216,6 +18224,7 @@ struct CMUXCLI { clientName: CMUXCLI.codexTeamsWatcherClientName, version: CMUXCLI.codexTeamsClientVersion ) + resetConnectionSubscriptions() try backfillLoadedThreads(connection: connection) try listenForNotifications(connection: connection) } catch { @@ -18260,6 +18269,7 @@ struct CMUXCLI { allowThreadSubscribe: Bool = true ) throws { guard let method = message["method"] as? String else { return } + cacheApprovalItemIfPresent(message, method: method) if let requestId = message["id"], try handleApprovalRequest(message, method: method, requestId: requestId, connection: connection) { return @@ -18316,21 +18326,71 @@ struct CMUXCLI { } } + private func resetConnectionSubscriptions() { + stateLock.lock() + subscribedThreadIds.removeAll(keepingCapacity: true) + stateLock.unlock() + } + + private func cacheApprovalItemIfPresent(_ message: [String: Any], method: String) { + guard method == "item/started" || method == "item/completed" || method == "item/fileChange/patchUpdated", + let params = message["params"] as? [String: Any] else { + return + } + if let item = params["item"] as? [String: Any], + let itemId = CMUXCLI.stringValue(in: item, keys: ["id"]) { + cacheApprovalItem(item, itemId: itemId) + return + } + guard method == "item/fileChange/patchUpdated", + let itemId = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) else { + return + } + var item = cachedApprovalItem(itemId: itemId) ?? [ + "type": "fileChange", + "id": itemId + ] + if let changes = params["changes"] { + item["changes"] = changes + } + cacheApprovalItem(item, itemId: itemId) + } + + private func cacheApprovalItem(_ item: [String: Any], itemId: String) { + stateLock.lock() + defer { stateLock.unlock() } + if approvalItemById[itemId] == nil { + approvalItemOrder.append(itemId) + } + approvalItemById[itemId] = item + while approvalItemOrder.count > CMUXCLI.codexTeamsMaxCachedApprovalItems { + let evicted = approvalItemOrder.removeFirst() + approvalItemById.removeValue(forKey: evicted) + } + } + + private func cachedApprovalItem(itemId: String) -> [String: Any]? { + stateLock.lock() + defer { stateLock.unlock() } + return approvalItemById[itemId] + } + private func handleApprovalRequest( _ message: [String: Any], method: String, requestId: Any, connection: CodexTeamsAppServerConnection ) throws -> Bool { - guard method == "item/commandExecution/requestApproval" - || method == "item/fileChange/requestApproval" - else { return false } + guard CMUXCLI.codexTeamsApprovalMethods.contains(method) else { return false } guard let params = message["params"] as? [String: Any] else { return true } + let relatedItem = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) + .flatMap { cachedApprovalItem(itemId: $0) } let feedEvent = CMUXCLI.codexTeamsFeedEvent( method: method, requestId: requestId, params: params, - workspaceId: workspaceId + workspaceId: workspaceId, + relatedItem: relatedItem ) fputs("cmux codex-teams watcher forwarding approval \(method) request \(CMUXCLI.requestIdString(requestId)) to Feed\n", stderr) let response = try pushCodexApprovalToFeed(event: feedEvent) @@ -18562,7 +18622,8 @@ struct CMUXCLI { method: String, requestId: Any, params: [String: Any], - workspaceId: String + workspaceId: String, + relatedItem: [String: Any]? = nil ) -> [String: Any] { let threadId = stringValue(in: params, keys: ["threadId", "thread_id"]) ?? stringValue(in: params, keys: ["threadID", "thread_id"]) @@ -18573,11 +18634,20 @@ struct CMUXCLI { let cwd = stringValue(in: params, keys: ["cwd"]) let reason = stringValue(in: params, keys: ["reason"]) let command = stringValue(in: params, keys: ["command"]) - let toolName = method == "item/fileChange/requestApproval" ? "Write" : "Bash" + let toolName: String + switch method { + case "item/fileChange/requestApproval": + toolName = "Write" + case "item/permissions/requestApproval": + toolName = "request_permissions" + default: + toolName = "Bash" + } var toolInput: [String: Any] = [ "app_server_method": method, "request_id": requestIdString(requestId), - "item_id": itemId + "item_id": itemId, + "approval_params": params ] if let turnId { toolInput["turn_id"] = turnId } if let reason { toolInput["reason"] = reason } @@ -18592,6 +18662,35 @@ struct CMUXCLI { if let available = params["availableDecisions"] ?? params["available_decisions"] { toolInput["available_decisions"] = codexTeamsDecisionNames(available) } + if let permissions = params["permissions"] { + toolInput["permissions"] = permissions + } + if let networkApprovalContext = params["networkApprovalContext"] ?? params["network_approval_context"] { + toolInput["network_approval_context"] = networkApprovalContext + } + if let additionalPermissions = params["additionalPermissions"] ?? params["additional_permissions"] { + toolInput["additional_permissions"] = additionalPermissions + } + if let commandActions = params["commandActions"] ?? params["command_actions"] { + toolInput["command_actions"] = commandActions + } + if let proposed = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { + toolInput["proposed_execpolicy_amendment"] = proposed + } + if let proposed = params["proposedNetworkPolicyAmendments"] ?? params["proposed_network_policy_amendments"] { + toolInput["proposed_network_policy_amendments"] = proposed + } + if let relatedItem { + toolInput["related_item"] = relatedItem + if command == nil, + let relatedCommand = relatedItem["command"] as? String { + toolInput["command"] = relatedCommand + } + if cwd == nil, + let relatedCwd = relatedItem["cwd"] as? String { + toolInput["cwd"] = relatedCwd + } + } var context: [String: Any] = [ "permissionMode": "codex app-server" @@ -18636,17 +18735,39 @@ struct CMUXCLI { return ["decision": codexTeamsCommandApprovalDecision(params: params, mode: mode)] case "item/fileChange/requestApproval": return ["decision": codexTeamsFileChangeApprovalDecision(params: params, mode: mode)] + case "item/permissions/requestApproval": + return codexTeamsPermissionsApprovalResponse(params: params, mode: mode) default: return nil } } - static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> String { + static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> Any { if mode == "deny" { return "decline" } if codexTeamsModeRequestsPersistentApproval(mode), codexTeamsAvailableDecisions(params).contains("acceptForSession") { return "acceptForSession" } + if codexTeamsModeRequestsPersistentApproval(mode), + codexTeamsAvailableDecisions(params).contains("acceptWithExecpolicyAmendment"), + let amendment = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { + return [ + "acceptWithExecpolicyAmendment": [ + "execpolicy_amendment": amendment + ] + ] + } + if codexTeamsModeRequestsPersistentApproval(mode), + codexTeamsAvailableDecisions(params).contains("applyNetworkPolicyAmendment"), + let amendments = (params["proposedNetworkPolicyAmendments"] as? [Any]) + ?? (params["proposed_network_policy_amendments"] as? [Any]), + let amendment = amendments.first { + return [ + "applyNetworkPolicyAmendment": [ + "network_policy_amendment": amendment + ] + ] + } return "accept" } @@ -18659,6 +18780,19 @@ struct CMUXCLI { return "accept" } + static func codexTeamsPermissionsApprovalResponse(params: [String: Any], mode: String) -> [String: Any] { + if mode == "deny" { + return [ + "permissions": [String: Any](), + "scope": "turn" + ] + } + return [ + "permissions": params["permissions"] ?? [String: Any](), + "scope": codexTeamsModeRequestsPersistentApproval(mode) ? "session" : "turn" + ] + } + static func codexTeamsModeRequestsPersistentApproval(_ mode: String) -> Bool { mode == "always" || mode == "all" || mode == "bypass" } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index cf840dff848d..14cb5c2cf827 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -80,9 +80,21 @@ final class FeedCoordinatorTests: XCTestCase { "command": "touch /tmp/cmux-security-review", "cwd": "/tmp/project", "reason": "requires approval", + "additionalPermissions": [ + "fileSystem": ["write": ["/tmp/project"]] + ], + "networkApprovalContext": ["host": "example.com"], + "commandActions": [["type": "write", "path": "/tmp/cmux-security-review"]], + "proposedExecpolicyAmendment": [["kind": "prefix", "value": "touch"]], "availableDecisions": ["accept", "acceptForSession", "decline"] ], - workspaceId: "workspace-1" + workspaceId: "workspace-1", + relatedItem: [ + "type": "commandExecution", + "id": "call-1", + "command": "touch /tmp/cmux-security-review", + "cwd": "/tmp/project" + ] ) XCTAssertEqual(event["session_id"] as? String, "codex-thread-1") @@ -99,12 +111,78 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertEqual(toolInput["item_id"] as? String, "approval-1") XCTAssertEqual(toolInput["turn_id"] as? String, "turn-1") XCTAssertEqual(toolInput["command"] as? String, "touch /tmp/cmux-security-review") + XCTAssertNotNil(toolInput["approval_params"]) + XCTAssertNotNil(toolInput["additional_permissions"]) + XCTAssertNotNil(toolInput["network_approval_context"]) + XCTAssertNotNil(toolInput["command_actions"]) + XCTAssertNotNil(toolInput["proposed_execpolicy_amendment"]) + XCTAssertEqual((toolInput["related_item"] as? [String: Any])?["type"] as? String, "commandExecution") let context = try XCTUnwrap(event["context"] as? [String: Any]) XCTAssertEqual(context["permissionMode"] as? String, "codex app-server") XCTAssertEqual(context["assistantPreamble"] as? String, "requires approval") } + func testCodexAppServerPermissionsApprovalBuildsFeedEventAndResponse() throws { + let permissions: [String: Any] = [ + "network": ["enabled": true], + "fileSystem": [ + "read": ["/tmp/read"], + "write": ["/tmp/write"] + ] + ] + let event = CMUXCLI.codexTeamsFeedEvent( + method: "item/permissions/requestApproval", + requestId: "permissions-request", + params: [ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "permissions-call", + "environmentId": "local", + "cwd": "/tmp/project", + "reason": "Need broader access", + "permissions": permissions + ], + workspaceId: "workspace-1" + ) + + XCTAssertEqual(event["tool_name"] as? String, "request_permissions") + XCTAssertEqual(event["_opencode_request_id"] as? String, "codex-app-server-permissions-call") + let toolInput = try XCTUnwrap(event["tool_input"] as? [String: Any]) + XCTAssertEqual(toolInput["app_server_method"] as? String, "item/permissions/requestApproval") + XCTAssertNotNil(toolInput["approval_params"]) + XCTAssertNotNil(toolInput["permissions"]) + + let once = try XCTUnwrap( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/permissions/requestApproval", + params: ["permissions": permissions], + mode: "once" + ) + ) + XCTAssertEqual(once["scope"] as? String, "turn") + XCTAssertNotNil(once["permissions"]) + + let always = try XCTUnwrap( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/permissions/requestApproval", + params: ["permissions": permissions], + mode: "always" + ) + ) + XCTAssertEqual(always["scope"] as? String, "session") + + let deny = try XCTUnwrap( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/permissions/requestApproval", + params: ["permissions": permissions], + mode: "deny" + ) + ) + XCTAssertEqual(deny["scope"] as? String, "turn") + XCTAssertEqual((deny["permissions"] as? [String: Any])?.isEmpty, true) + } + func testCodexAppServerApprovalResponseFollowsFeedDecision() { let params: [String: Any] = [ "availableDecisions": ["accept", "acceptForSession", "decline"] @@ -125,6 +203,15 @@ final class FeedCoordinatorTests: XCTestCase { )?["decision"] as? String, "acceptForSession" ) + let amendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: [ + "availableDecisions": [["acceptWithExecpolicyAmendment": [:]]], + "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] + ], + mode: "always" + )?["decision"] as? [String: Any] + XCTAssertNotNil(amendmentDecision?["acceptWithExecpolicyAmendment"]) XCTAssertEqual( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", From 851b3378d6ca02c275a697b6dbc82e8ceabfa701 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:03:06 -0700 Subject: [PATCH 06/29] Align Feed TUI Codex permission modes --- CLI/cmux.swift | 12 ++++++++++-- cmuxTests/FeedCoordinatorTests.swift | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index dc307dec3d63..bfcdd8790e2e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -30524,11 +30524,19 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } private func feedTUISourceSupportsPersistentPermissionModes(_ source: String) -> Bool { - source != "codex" + CMUXCLI.feedSourceSupportsPersistentPermissionModes(source) } private func feedTUISourceSupportsBypassPermissions(_ source: String) -> Bool { - source != "codex" && source != "claude" + CMUXCLI.feedSourceSupportsBypassPermissions(source) + } + + static func feedSourceSupportsPersistentPermissionModes(_ source: String) -> Bool { + source != "hermes-agent" + } + + static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { + source != "codex" && source != "claude" && source != "hermes-agent" } private func resolveFeedTUIItem( diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 14cb5c2cf827..4f26e69abb5a 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -57,15 +57,23 @@ final class FeedCoordinatorTests: XCTestCase { func testClaudePermissionActionPolicyKeepsBypassUserOwned() { XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) + XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("claude")) + XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("claude")) XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .codex)) + XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("codex")) + XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("codex")) XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) XCTAssertTrue(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) + XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("opencode")) + XCTAssertTrue(CMUXCLI.feedSourceSupportsBypassPermissions("opencode")) XCTAssertFalse(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .hermesAgent)) XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) + XCTAssertFalse(CMUXCLI.feedSourceSupportsPersistentPermissionModes("hermes-agent")) + XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("hermes-agent")) } func testCodexAppServerApprovalBuildsActionableFeedEvent() throws { From da2eb3a72a9c6ece76df953245f706ffef5e34bf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:26:15 -0700 Subject: [PATCH 07/29] Handle unsupported Codex app-server requests --- CLI/cmux.swift | 50 +++++++++++++++++++++++++--- cmuxTests/FeedCoordinatorTests.swift | 21 ++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bfcdd8790e2e..26e34a0cb310 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18040,6 +18040,21 @@ struct CMUXCLI { ], timeout: timeout) } + func respondError( + requestId: Any, + code: Int, + message: String, + timeout: TimeInterval = 10 + ) throws { + try sendObject([ + "id": requestId, + "error": [ + "code": code, + "message": message + ] + ], timeout: timeout) + } + func request( method: String, params: [String: Any]? = nil, @@ -18274,6 +18289,14 @@ struct CMUXCLI { try handleApprovalRequest(message, method: method, requestId: requestId, connection: connection) { return } + if let requestId = message["id"] { + try connection.respondError( + requestId: requestId, + code: -32601, + message: "cmux Codex Teams watcher does not handle \(method)" + ) + return + } guard method.hasPrefix("thread/"), let params = message["params"] as? [String: Any], let threadObject = params["thread"] as? [String: Any], @@ -18382,7 +18405,14 @@ struct CMUXCLI { connection: CodexTeamsAppServerConnection ) throws -> Bool { guard CMUXCLI.codexTeamsApprovalMethods.contains(method) else { return false } - guard let params = message["params"] as? [String: Any] else { return true } + guard let params = message["params"] as? [String: Any] else { + try connection.respondError( + requestId: requestId, + code: -32602, + message: "\(method) is missing params" + ) + return true + } let relatedItem = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) .flatMap { cachedApprovalItem(itemId: $0) } let feedEvent = CMUXCLI.codexTeamsFeedEvent( @@ -18744,12 +18774,23 @@ struct CMUXCLI { static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> Any { if mode == "deny" { return "decline" } + if mode == "all" || mode == "bypass", + let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { + return decision + } if codexTeamsModeRequestsPersistentApproval(mode), codexTeamsAvailableDecisions(params).contains("acceptForSession") { return "acceptForSession" } if codexTeamsModeRequestsPersistentApproval(mode), - codexTeamsAvailableDecisions(params).contains("acceptWithExecpolicyAmendment"), + let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { + return decision + } + return "accept" + } + + static func codexTeamsCommandApprovalAmendmentDecision(params: [String: Any]) -> Any? { + if codexTeamsAvailableDecisions(params).contains("acceptWithExecpolicyAmendment"), let amendment = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { return [ "acceptWithExecpolicyAmendment": [ @@ -18757,8 +18798,7 @@ struct CMUXCLI { ] ] } - if codexTeamsModeRequestsPersistentApproval(mode), - codexTeamsAvailableDecisions(params).contains("applyNetworkPolicyAmendment"), + if codexTeamsAvailableDecisions(params).contains("applyNetworkPolicyAmendment"), let amendments = (params["proposedNetworkPolicyAmendments"] as? [Any]) ?? (params["proposed_network_policy_amendments"] as? [Any]), let amendment = amendments.first { @@ -18768,7 +18808,7 @@ struct CMUXCLI { ] ] } - return "accept" + return nil } static func codexTeamsFileChangeApprovalDecision(params: [String: Any], mode: String) -> String { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 4f26e69abb5a..26b3da1461ad 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -220,6 +220,27 @@ final class FeedCoordinatorTests: XCTestCase { mode: "always" )?["decision"] as? [String: Any] XCTAssertNotNil(amendmentDecision?["acceptWithExecpolicyAmendment"]) + let mixedParams: [String: Any] = [ + "availableDecisions": [ + "acceptForSession", + ["acceptWithExecpolicyAmendment": [:]] + ], + "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] + ] + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: mixedParams, + mode: "always" + )?["decision"] as? String, + "acceptForSession" + ) + let allToolsDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: mixedParams, + mode: "all" + )?["decision"] as? [String: Any] + XCTAssertNotNil(allToolsDecision?["acceptWithExecpolicyAmendment"]) XCTAssertEqual( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", From f310df086772740b315303e3cb1ca4863b926031 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:32:47 -0700 Subject: [PATCH 08/29] Close Codex watcher on unsupported requests --- CLI/cmux.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 26e34a0cb310..79349869f8dd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18289,13 +18289,11 @@ struct CMUXCLI { try handleApprovalRequest(message, method: method, requestId: requestId, connection: connection) { return } - if let requestId = message["id"] { - try connection.respondError( - requestId: requestId, - code: -32601, - message: "cmux Codex Teams watcher does not handle \(method)" - ) - return + if message["id"] != nil { + // This watcher is not the owning interactive client. Close this + // auxiliary connection instead of rejecting requests the Codex + // UI may still be able to satisfy. + throw CLIError(message: "Codex app-server sent unsupported watcher request \(method)") } guard method.hasPrefix("thread/"), let params = message["params"] as? [String: Any], From b2ff9f4eaf5f8464015388fe731c4f77e7324a9a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:46:00 -0700 Subject: [PATCH 09/29] Preserve persistent Codex file approvals --- CLI/cmux.swift | 11 +++++++++-- cmuxTests/FeedCoordinatorTests.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 79349869f8dd..92117dbdd170 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18811,8 +18811,8 @@ struct CMUXCLI { static func codexTeamsFileChangeApprovalDecision(params: [String: Any], mode: String) -> String { if mode == "deny" { return "decline" } - if codexTeamsModeRequestsPersistentApproval(mode), - codexTeamsAvailableDecisions(params).contains("acceptForSession") { + if codexTeamsModeRequestsPersistentApproval(mode) + && codexTeamsDecisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" } return "accept" @@ -18842,6 +18842,13 @@ struct CMUXCLI { return Set(codexTeamsDecisionNames(raw)) } + static func codexTeamsDecisionAvailableOrUnspecified(_ decision: String, params: [String: Any]) -> Bool { + guard params["availableDecisions"] != nil || params["available_decisions"] != nil else { + return true + } + return codexTeamsAvailableDecisions(params).contains(decision) + } + static func codexTeamsDecisionNames(_ raw: Any) -> [String] { let values = raw as? [Any] ?? [] return values.compactMap { value in diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 26b3da1461ad..fa85a2c96614 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -249,6 +249,22 @@ final class FeedCoordinatorTests: XCTestCase { )?["decision"] as? String, "accept" ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/fileChange/requestApproval", + params: [:], + mode: "always" + )?["decision"] as? String, + "acceptForSession" + ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/fileChange/requestApproval", + params: ["availableDecisions": ["accept", "decline"]], + mode: "always" + )?["decision"] as? String, + "accept" + ) XCTAssertEqual( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", From f5f8d0ee8b884014e7d2d77aeda7a462e6e039a1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:51:15 -0700 Subject: [PATCH 10/29] Close Codex watcher on unresolved approvals --- CLI/cmux.swift | 6 ++++-- cmuxTests/FeedCoordinatorTests.swift | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 92117dbdd170..1edc5ff42eb2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18423,7 +18423,9 @@ struct CMUXCLI { fputs("cmux codex-teams watcher forwarding approval \(method) request \(CMUXCLI.requestIdString(requestId)) to Feed\n", stderr) let response = try pushCodexApprovalToFeed(event: feedEvent) guard let decision = CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: response) else { - return true + // Close this auxiliary app-server connection instead of + // silently consuming an approval request Codex still needs. + throw CLIError(message: "Codex Feed did not resolve approval request \(CMUXCLI.requestIdString(requestId))") } guard let result = CMUXCLI.codexTeamsAppServerApprovalResponse( method: method, @@ -18777,7 +18779,7 @@ struct CMUXCLI { return decision } if codexTeamsModeRequestsPersistentApproval(mode), - codexTeamsAvailableDecisions(params).contains("acceptForSession") { + codexTeamsDecisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" } if codexTeamsModeRequestsPersistentApproval(mode), diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index fa85a2c96614..2b6c9f56a347 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -211,6 +211,14 @@ final class FeedCoordinatorTests: XCTestCase { )?["decision"] as? String, "acceptForSession" ) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: [:], + mode: "always" + )?["decision"] as? String, + "acceptForSession" + ) let amendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ From 01629cbf99f30467e88c649998b30fd81e05a4de Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:01:07 -0700 Subject: [PATCH 11/29] Honor Codex advertised approval decisions --- CLI/cmux.swift | 28 ++++++++++++++++++++++++---- cmuxTests/FeedCoordinatorTests.swift | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1edc5ff42eb2..eb0275d1a840 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18773,7 +18773,7 @@ struct CMUXCLI { } static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> Any { - if mode == "deny" { return "decline" } + if mode == "deny" { return codexTeamsRejectApprovalDecision(params: params) } if mode == "all" || mode == "bypass", let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { return decision @@ -18786,7 +18786,13 @@ struct CMUXCLI { let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { return decision } - return "accept" + if codexTeamsDecisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } + if let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { + return decision + } + return codexTeamsRejectApprovalDecision(params: params) } static func codexTeamsCommandApprovalAmendmentDecision(params: [String: Any]) -> Any? { @@ -18812,12 +18818,15 @@ struct CMUXCLI { } static func codexTeamsFileChangeApprovalDecision(params: [String: Any], mode: String) -> String { - if mode == "deny" { return "decline" } + if mode == "deny" { return codexTeamsRejectApprovalDecision(params: params) } if codexTeamsModeRequestsPersistentApproval(mode) && codexTeamsDecisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" } - return "accept" + if codexTeamsDecisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } + return codexTeamsRejectApprovalDecision(params: params) } static func codexTeamsPermissionsApprovalResponse(params: [String: Any], mode: String) -> [String: Any] { @@ -18851,6 +18860,17 @@ struct CMUXCLI { return codexTeamsAvailableDecisions(params).contains(decision) } + static func codexTeamsRejectApprovalDecision(params: [String: Any]) -> String { + let available = codexTeamsAvailableDecisions(params) + if available.contains("decline") || available.isEmpty { + return "decline" + } + if available.contains("cancel") { + return "cancel" + } + return "decline" + } + static func codexTeamsDecisionNames(_ raw: Any) -> [String] { let values = raw as? [Any] ?? [] return values.compactMap { value in diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 2b6c9f56a347..1181b96934ae 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -228,6 +228,15 @@ final class FeedCoordinatorTests: XCTestCase { mode: "always" )?["decision"] as? [String: Any] XCTAssertNotNil(amendmentDecision?["acceptWithExecpolicyAmendment"]) + let onceAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: [ + "availableDecisions": [["applyNetworkPolicyAmendment": [:]]], + "proposedNetworkPolicyAmendments": [["host": "example.com"]] + ], + mode: "once" + )?["decision"] as? [String: Any] + XCTAssertNotNil(onceAmendmentDecision?["applyNetworkPolicyAmendment"]) let mixedParams: [String: Any] = [ "availableDecisions": [ "acceptForSession", @@ -249,6 +258,14 @@ final class FeedCoordinatorTests: XCTestCase { mode: "all" )?["decision"] as? [String: Any] XCTAssertNotNil(allToolsDecision?["acceptWithExecpolicyAmendment"]) + XCTAssertEqual( + CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: ["availableDecisions": ["decline"]], + mode: "once" + )?["decision"] as? String, + "decline" + ) XCTAssertEqual( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", From 1bf0e8e80c24c9d745b52d492611cb10cb271133 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:07:24 -0700 Subject: [PATCH 12/29] Bound Codex approval item cache --- CLI/cmux.swift | 42 ++++++++++++++++++++++++++-- cmuxTests/FeedCoordinatorTests.swift | 32 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eb0275d1a840..8e8c3267e873 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18383,7 +18383,7 @@ struct CMUXCLI { if approvalItemById[itemId] == nil { approvalItemOrder.append(itemId) } - approvalItemById[itemId] = item + approvalItemById[itemId] = CMUXCLI.codexTeamsApprovalItemSnapshot(item) while approvalItemOrder.count > CMUXCLI.codexTeamsMaxCachedApprovalItems { let evicted = approvalItemOrder.removeFirst() approvalItemById.removeValue(forKey: evicted) @@ -18796,7 +18796,7 @@ struct CMUXCLI { } static func codexTeamsCommandApprovalAmendmentDecision(params: [String: Any]) -> Any? { - if codexTeamsAvailableDecisions(params).contains("acceptWithExecpolicyAmendment"), + if codexTeamsDecisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", params: params), let amendment = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { return [ "acceptWithExecpolicyAmendment": [ @@ -18804,7 +18804,7 @@ struct CMUXCLI { ] ] } - if codexTeamsAvailableDecisions(params).contains("applyNetworkPolicyAmendment"), + if codexTeamsDecisionAvailableOrUnspecified("applyNetworkPolicyAmendment", params: params), let amendments = (params["proposedNetworkPolicyAmendments"] as? [Any]) ?? (params["proposed_network_policy_amendments"] as? [Any]), let amendment = amendments.first { @@ -18817,6 +18817,42 @@ struct CMUXCLI { return nil } + static func codexTeamsApprovalItemSnapshot(_ item: [String: Any]) -> [String: Any] { + var snapshot: [String: Any] = [:] + for key in ["id", "type", "threadId", "thread_id", "turnId", "turn_id", "command", "cwd", "path", "status"] { + if let value = item[key], + let bounded = codexTeamsBoundedApprovalItemValue(value) { + snapshot[key] = bounded + } + } + if let changes = item["changes"] as? [[String: Any]] { + snapshot["changes"] = changes.prefix(20).map { change in + var changeSnapshot: [String: Any] = [:] + for key in ["path", "kind", "type", "status"] { + if let value = change[key], + let bounded = codexTeamsBoundedApprovalItemValue(value) { + changeSnapshot[key] = bounded + } + } + return changeSnapshot + } + } + return snapshot + } + + static func codexTeamsBoundedApprovalItemValue(_ value: Any) -> Any? { + if let string = value as? String { + let limit = 4_096 + guard string.count > limit else { return string } + let index = string.index(string.startIndex, offsetBy: limit) + return String(string[.. String { if mode == "deny" { return codexTeamsRejectApprovalDecision(params: params) } if codexTeamsModeRequestsPersistentApproval(mode) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 1181b96934ae..a19972672d4d 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -237,6 +237,14 @@ final class FeedCoordinatorTests: XCTestCase { mode: "once" )?["decision"] as? [String: Any] XCTAssertNotNil(onceAmendmentDecision?["applyNetworkPolicyAmendment"]) + let unspecifiedAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: [ + "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] + ], + mode: "all" + )?["decision"] as? [String: Any] + XCTAssertNotNil(unspecifiedAmendmentDecision?["acceptWithExecpolicyAmendment"]) let mixedParams: [String: Any] = [ "availableDecisions": [ "acceptForSession", @@ -301,6 +309,30 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertNil(CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: ["status": "timed_out"])) } + func testCodexApprovalItemSnapshotStripsLargePayloads() { + let snapshot = CMUXCLI.codexTeamsApprovalItemSnapshot([ + "id": "call-1", + "type": "commandExecution", + "command": String(repeating: "x", count: 5_000), + "cwd": "/tmp/project", + "output": String(repeating: "y", count: 100_000), + "changes": [ + [ + "path": "/tmp/file.txt", + "diff": String(repeating: "z", count: 100_000) + ] + ] + ]) + + XCTAssertEqual(snapshot["id"] as? String, "call-1") + XCTAssertEqual(snapshot["cwd"] as? String, "/tmp/project") + XCTAssertEqual((snapshot["command"] as? String)?.count, 4_096) + XCTAssertNil(snapshot["output"]) + let changes = snapshot["changes"] as? [[String: Any]] + XCTAssertEqual(changes?.first?["path"] as? String, "/tmp/file.txt") + XCTAssertNil(changes?.first?["diff"]) + } + func testBlockingIngestExpiresItemWhenHookTimesOut() async { await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) From 79313b8514ee5d99c429c281930359555d3c9ca8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:20:17 -0700 Subject: [PATCH 13/29] Convert Feed coordinator tests to Swift Testing --- .../CLIGenericHookPersistenceTests.swift | 17 +- cmuxTests/FeedCoordinatorTests.swift | 268 +++++++++--------- 2 files changed, 132 insertions(+), 153 deletions(-) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 94d009a09c5c..a521c3f08dbc 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -2915,12 +2915,11 @@ extension CLINotifyProcessIntegrationRegressionTests { .appendingPathComponent("cmux-session.json", isDirectory: false) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) - let codexHookEntries = hooks.values + let allCommands = hooks.values .compactMap { $0 as? [[String: Any]] } .flatMap { $0 } .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } - let allCommands = codexHookEntries .compactMap { $0["command"] as? String } XCTAssertFalse(allCommands.isEmpty) @@ -3140,20 +3139,6 @@ extension CLINotifyProcessIntegrationRegressionTests { 1, "Codex setup should collapse duplicate cmux-owned prompt hooks to one entry, saw \(allCommands)" ) - XCTAssertTrue( - codexHookEntries.contains { - ($0["command"] as? String)?.contains("hooks codex prompt-submit") == true - && ($0["timeout"] as? Int) == 5 - }, - "Codex lifecycle hooks must use Codex's second-based timeout field, saw \(codexHookEntries)" - ) - XCTAssertTrue( - codexHookEntries.contains { - ($0["command"] as? String)?.contains("hooks feed --source codex --event PermissionRequest") == true - && ($0["timeout"] as? Int) == 5 - }, - "Codex Feed hooks are telemetry and must use a short second-based timeout, saw \(codexHookEntries)" - ) } func testGrokHookInstallRejectsFileAtHooksDirectory() throws { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index a19972672d4d..89d68de3c4eb 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -1,4 +1,5 @@ -import XCTest +import Foundation +import Testing import CMUXWorkstream #if canImport(cmux_DEV) @@ -7,76 +8,80 @@ import CMUXWorkstream @testable import cmux #endif -final class FeedCoordinatorTests: XCTestCase { - func testCodexTeamsResolvesExplicitWorkingDirectoryFlags() throws { +@Suite("Feed coordinator") +struct FeedCoordinatorTests { + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsResolvedWorkingDirectory( commandArgs: ["-C", "child", "prompt"], baseDirectory: base - ), - "/tmp/cmux-base/child" + ) == "/tmp/cmux-base/child" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsResolvedWorkingDirectory( commandArgs: ["--cwd=/tmp/cmux-review", "--cd", "/tmp/cmux-final"], baseDirectory: base - ), - "/tmp/cmux-final" + ) == "/tmp/cmux-final" ) - XCTAssertNil( + #expect( CMUXCLI.codexTeamsResolvedWorkingDirectory( commandArgs: ["--", "-C", "/tmp/inside-prompt"], baseDirectory: base - ) + ) == nil ) } - func testCodexTeamsValidatesExplicitWorkingDirectoryExists() throws { + @Test func codexTeamsValidatesExplicitWorkingDirectoryExists() throws { let existing = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-codex-teams-cwd-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: existing) } - XCTAssertNoThrow( + do { try CMUXCLI.validateCodexTeamsWorkingDirectory( commandArgs: ["-C", existing.path], baseDirectory: "/tmp" ) - ) + } catch { + Issue.record("existing Codex Teams cwd should validate: \(error)") + } - XCTAssertThrowsError( + do { try CMUXCLI.validateCodexTeamsWorkingDirectory( commandArgs: ["-C", existing.appendingPathComponent("missing").path], baseDirectory: "/tmp" ) - ) + Issue.record("missing Codex Teams cwd should throw") + } catch { + // expected + } } - func testClaudePermissionActionPolicyKeepsBypassUserOwned() { - XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) - XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) - XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("claude")) - XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("claude")) - - XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) - XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .codex)) - XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("codex")) - XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("codex")) - - XCTAssertTrue(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) - XCTAssertTrue(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) - XCTAssertTrue(CMUXCLI.feedSourceSupportsPersistentPermissionModes("opencode")) - XCTAssertTrue(CMUXCLI.feedSourceSupportsBypassPermissions("opencode")) - - XCTAssertFalse(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .hermesAgent)) - XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) - XCTAssertFalse(CMUXCLI.feedSourceSupportsPersistentPermissionModes("hermes-agent")) - XCTAssertFalse(CMUXCLI.feedSourceSupportsBypassPermissions("hermes-agent")) + @Test func claudePermissionActionPolicyKeepsBypassUserOwned() { + #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) + #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) + #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("claude")) + #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("claude")) + + #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) + #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .codex)) + #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("codex")) + #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("codex")) + + #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) + #expect(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) + #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("opencode")) + #expect(CMUXCLI.feedSourceSupportsBypassPermissions("opencode")) + + #expect(!FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .hermesAgent)) + #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) + #expect(!CMUXCLI.feedSourceSupportsPersistentPermissionModes("hermes-agent")) + #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("hermes-agent")) } - func testCodexAppServerApprovalBuildsActionableFeedEvent() throws { + @Test func codexAppServerApprovalBuildsActionableFeedEvent() throws { let event = CMUXCLI.codexTeamsFeedEvent( method: "item/commandExecution/requestApproval", requestId: 41, @@ -105,33 +110,33 @@ final class FeedCoordinatorTests: XCTestCase { ] ) - XCTAssertEqual(event["session_id"] as? String, "codex-thread-1") - XCTAssertEqual(event["hook_event_name"] as? String, "PermissionRequest") - XCTAssertEqual(event["_source"] as? String, "codex") - XCTAssertEqual(event["workspace_id"] as? String, "workspace-1") - XCTAssertEqual(event["_opencode_request_id"] as? String, "codex-app-server-approval-1") - XCTAssertEqual(event["tool_name"] as? String, "Bash") - XCTAssertEqual(event["cwd"] as? String, "/tmp/project") - - let toolInput = try XCTUnwrap(event["tool_input"] as? [String: Any]) - XCTAssertEqual(toolInput["app_server_method"] as? String, "item/commandExecution/requestApproval") - XCTAssertEqual(toolInput["request_id"] as? String, "41") - XCTAssertEqual(toolInput["item_id"] as? String, "approval-1") - XCTAssertEqual(toolInput["turn_id"] as? String, "turn-1") - XCTAssertEqual(toolInput["command"] as? String, "touch /tmp/cmux-security-review") - XCTAssertNotNil(toolInput["approval_params"]) - XCTAssertNotNil(toolInput["additional_permissions"]) - XCTAssertNotNil(toolInput["network_approval_context"]) - XCTAssertNotNil(toolInput["command_actions"]) - XCTAssertNotNil(toolInput["proposed_execpolicy_amendment"]) - XCTAssertEqual((toolInput["related_item"] as? [String: Any])?["type"] as? String, "commandExecution") - - let context = try XCTUnwrap(event["context"] as? [String: Any]) - XCTAssertEqual(context["permissionMode"] as? String, "codex app-server") - XCTAssertEqual(context["assistantPreamble"] as? String, "requires approval") + #expect(event["session_id"] as? String == "codex-thread-1") + #expect(event["hook_event_name"] as? String == "PermissionRequest") + #expect(event["_source"] as? String == "codex") + #expect(event["workspace_id"] as? String == "workspace-1") + #expect(event["_opencode_request_id"] as? String == "codex-app-server-approval-1") + #expect(event["tool_name"] as? String == "Bash") + #expect(event["cwd"] as? String == "/tmp/project") + + let toolInput = try #require(event["tool_input"] as? [String: Any]) + #expect(toolInput["app_server_method"] as? String == "item/commandExecution/requestApproval") + #expect(toolInput["request_id"] as? String == "41") + #expect(toolInput["item_id"] as? String == "approval-1") + #expect(toolInput["turn_id"] as? String == "turn-1") + #expect(toolInput["command"] as? String == "touch /tmp/cmux-security-review") + #expect(toolInput["approval_params"] != nil) + #expect(toolInput["additional_permissions"] != nil) + #expect(toolInput["network_approval_context"] != nil) + #expect(toolInput["command_actions"] != nil) + #expect(toolInput["proposed_execpolicy_amendment"] != nil) + #expect((toolInput["related_item"] as? [String: Any])?["type"] as? String == "commandExecution") + + let context = try #require(event["context"] as? [String: Any]) + #expect(context["permissionMode"] as? String == "codex app-server") + #expect(context["assistantPreamble"] as? String == "requires approval") } - func testCodexAppServerPermissionsApprovalBuildsFeedEventAndResponse() throws { + @Test func codexAppServerPermissionsApprovalBuildsFeedEventAndResponse() throws { let permissions: [String: Any] = [ "network": ["enabled": true], "fileSystem": [ @@ -154,70 +159,67 @@ final class FeedCoordinatorTests: XCTestCase { workspaceId: "workspace-1" ) - XCTAssertEqual(event["tool_name"] as? String, "request_permissions") - XCTAssertEqual(event["_opencode_request_id"] as? String, "codex-app-server-permissions-call") - let toolInput = try XCTUnwrap(event["tool_input"] as? [String: Any]) - XCTAssertEqual(toolInput["app_server_method"] as? String, "item/permissions/requestApproval") - XCTAssertNotNil(toolInput["approval_params"]) - XCTAssertNotNil(toolInput["permissions"]) + #expect(event["tool_name"] as? String == "request_permissions") + #expect(event["_opencode_request_id"] as? String == "codex-app-server-permissions-call") + let toolInput = try #require(event["tool_input"] as? [String: Any]) + #expect(toolInput["app_server_method"] as? String == "item/permissions/requestApproval") + #expect(toolInput["approval_params"] != nil) + #expect(toolInput["permissions"] != nil) - let once = try XCTUnwrap( + let once = try #require( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "once" ) ) - XCTAssertEqual(once["scope"] as? String, "turn") - XCTAssertNotNil(once["permissions"]) + #expect(once["scope"] as? String == "turn") + #expect(once["permissions"] != nil) - let always = try XCTUnwrap( + let always = try #require( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "always" ) ) - XCTAssertEqual(always["scope"] as? String, "session") + #expect(always["scope"] as? String == "session") - let deny = try XCTUnwrap( + let deny = try #require( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "deny" ) ) - XCTAssertEqual(deny["scope"] as? String, "turn") - XCTAssertEqual((deny["permissions"] as? [String: Any])?.isEmpty, true) + #expect(deny["scope"] as? String == "turn") + #expect((deny["permissions"] as? [String: Any])?.isEmpty == true) } - func testCodexAppServerApprovalResponseFollowsFeedDecision() { + @Test func codexAppServerApprovalResponseFollowsFeedDecision() { let params: [String: Any] = [ "availableDecisions": ["accept", "acceptForSession", "decline"] ] - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: [ "status": "resolved", "decision": ["kind": "permission", "mode": "always"] - ]), - "always" + ]) == "always" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: params, mode: "always" - )?["decision"] as? String, - "acceptForSession" + )?["decision"] as? String == "acceptForSession" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [:], mode: "always" - )?["decision"] as? String, - "acceptForSession" + )?["decision"] as? String == "acceptForSession" ) let amendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", @@ -227,7 +229,7 @@ final class FeedCoordinatorTests: XCTestCase { ], mode: "always" )?["decision"] as? [String: Any] - XCTAssertNotNil(amendmentDecision?["acceptWithExecpolicyAmendment"]) + #expect(amendmentDecision?["acceptWithExecpolicyAmendment"] != nil) let onceAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ @@ -236,7 +238,7 @@ final class FeedCoordinatorTests: XCTestCase { ], mode: "once" )?["decision"] as? [String: Any] - XCTAssertNotNil(onceAmendmentDecision?["applyNetworkPolicyAmendment"]) + #expect(onceAmendmentDecision?["applyNetworkPolicyAmendment"] != nil) let unspecifiedAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ @@ -244,7 +246,7 @@ final class FeedCoordinatorTests: XCTestCase { ], mode: "all" )?["decision"] as? [String: Any] - XCTAssertNotNil(unspecifiedAmendmentDecision?["acceptWithExecpolicyAmendment"]) + #expect(unspecifiedAmendmentDecision?["acceptWithExecpolicyAmendment"] != nil) let mixedParams: [String: Any] = [ "availableDecisions": [ "acceptForSession", @@ -252,64 +254,58 @@ final class FeedCoordinatorTests: XCTestCase { ], "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] ] - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: mixedParams, mode: "always" - )?["decision"] as? String, - "acceptForSession" + )?["decision"] as? String == "acceptForSession" ) let allToolsDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: mixedParams, mode: "all" )?["decision"] as? [String: Any] - XCTAssertNotNil(allToolsDecision?["acceptWithExecpolicyAmendment"]) - XCTAssertEqual( + #expect(allToolsDecision?["acceptWithExecpolicyAmendment"] != nil) + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: ["availableDecisions": ["decline"]], mode: "once" - )?["decision"] as? String, - "decline" + )?["decision"] as? String == "decline" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", params: [:], mode: "once" - )?["decision"] as? String, - "accept" + )?["decision"] as? String == "accept" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", params: [:], mode: "always" - )?["decision"] as? String, - "acceptForSession" + )?["decision"] as? String == "acceptForSession" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/fileChange/requestApproval", params: ["availableDecisions": ["accept", "decline"]], mode: "always" - )?["decision"] as? String, - "accept" + )?["decision"] as? String == "accept" ) - XCTAssertEqual( + #expect( CMUXCLI.codexTeamsAppServerApprovalResponse( method: "item/commandExecution/requestApproval", params: params, mode: "deny" - )?["decision"] as? String, - "decline" + )?["decision"] as? String == "decline" ) - XCTAssertNil(CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: ["status": "timed_out"])) + #expect(CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: ["status": "timed_out"]) == nil) } - func testCodexApprovalItemSnapshotStripsLargePayloads() { + @Test func codexApprovalItemSnapshotStripsLargePayloads() throws { let snapshot = CMUXCLI.codexTeamsApprovalItemSnapshot([ "id": "call-1", "type": "commandExecution", @@ -324,16 +320,16 @@ final class FeedCoordinatorTests: XCTestCase { ] ]) - XCTAssertEqual(snapshot["id"] as? String, "call-1") - XCTAssertEqual(snapshot["cwd"] as? String, "/tmp/project") - XCTAssertEqual((snapshot["command"] as? String)?.count, 4_096) - XCTAssertNil(snapshot["output"]) - let changes = snapshot["changes"] as? [[String: Any]] - XCTAssertEqual(changes?.first?["path"] as? String, "/tmp/file.txt") - XCTAssertNil(changes?.first?["diff"]) + #expect(snapshot["id"] as? String == "call-1") + #expect(snapshot["cwd"] as? String == "/tmp/project") + #expect((snapshot["command"] as? String)?.count == 4_096) + #expect(snapshot["output"] == nil) + let changes = try #require(snapshot["changes"] as? [[String: Any]]) + #expect(changes.first?["path"] as? String == "/tmp/file.txt") + #expect(changes.first?["diff"] == nil) } - func testBlockingIngestExpiresItemWhenHookTimesOut() async { + @Test func blockingIngestExpiresItemWhenHookTimesOut() async { await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) FeedCoordinator.shared.install(store: store) @@ -349,7 +345,7 @@ final class FeedCoordinatorTests: XCTestCase { requestId: "timeout-request" ) - let done = expectation(description: "blocking ingest timed out") + let done = DispatchSemaphore(value: 0) let resultBox = IngestResultBox() DispatchQueue.global(qos: .userInitiated).async { @@ -357,13 +353,13 @@ final class FeedCoordinatorTests: XCTestCase { event: event, waitTimeout: 0.05 ) - done.fulfill() + done.signal() } - await fulfillment(of: [done], timeout: 2) + #expect(done.wait(timeout: .now() + 2) == .success) guard case .timedOut = resultBox.value else { - XCTFail("expected feed.push to time out") + Issue.record("expected feed.push to time out") return } @@ -371,16 +367,16 @@ final class FeedCoordinatorTests: XCTestCase { FeedCoordinator.shared.store.items.first?.status } guard case .expired = status else { - XCTFail("timed-out hook item should be expired") + Issue.record("timed-out hook item should be expired") return } } - func testBlockingIngestSkipsNotificationWhenPermissionResolvesBeforeDisplay() async { + @Test func blockingIngestSkipsNotificationWhenPermissionResolvesBeforeDisplay() async { let requestId = "auto-allow-request" let notifications = NotificationRequestRecorder() - addTeardownBlock { + defer { Self.resetFeedCoordinatorTestHooks() } @@ -410,7 +406,7 @@ final class FeedCoordinatorTests: XCTestCase { requestId: requestId ) - let done = expectation(description: "blocking ingest resolved") + let done = DispatchSemaphore(value: 0) let resultBox = IngestResultBox() DispatchQueue.global(qos: .userInitiated).async { @@ -418,29 +414,27 @@ final class FeedCoordinatorTests: XCTestCase { event: event, waitTimeout: 1 ) - done.fulfill() + done.signal() } - await fulfillment(of: [done], timeout: 2) + #expect(done.wait(timeout: .now() + 2) == .success) await MainActor.run {} - if case .resolved(_, .permission(.once)) = resultBox.value { - // ok - } else { - XCTFail("expected auto-allowed permission request to resolve") + guard case .resolved(_, .permission(.once)) = resultBox.value else { + Issue.record("expected auto-allowed permission request to resolve") + return } let status = await MainActor.run { FeedCoordinator.shared.store.items.first?.status } - if case .resolved(.permission(.once), _) = status { - // ok - } else { - XCTFail("auto-allowed hook item should be resolved") + guard case .resolved(.permission(.once), _) = status else { + Issue.record("auto-allowed hook item should be resolved") + return } - XCTAssertTrue( + #expect( notifications.requestIds.isEmpty, "auto-allowed permission requests should not post native notifications" ) From 6e7dcda8fdfdde9e8580d1b4ff5757c25060195c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:30:02 -0700 Subject: [PATCH 14/29] Ignore unsupported Codex watcher requests --- CLI/cmux.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8e8c3267e873..0b8d3cbe4e8e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18290,10 +18290,10 @@ struct CMUXCLI { return } if message["id"] != nil { - // This watcher is not the owning interactive client. Close this - // auxiliary connection instead of rejecting requests the Codex - // UI may still be able to satisfy. - throw CLIError(message: "Codex app-server sent unsupported watcher request \(method)") + // This watcher is not the owning interactive client. Leave + // unsupported requests for the Codex UI instead of rejecting + // them or reconnecting into the same replayed request. + return } guard method.hasPrefix("thread/"), let params = message["params"] as? [String: Any], From 89b82c2be7888b538e885a9def4172ee88fb3977 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:48:07 -0700 Subject: [PATCH 15/29] Share Codex approval bridge with tests --- CLI/CodexTeamsApprovalBridge.swift | 362 +++++++++++++++++++++++++++ CLI/cmux.swift | 337 +++---------------------- cmux.xcodeproj/project.pbxproj | 6 + cmuxTests/FeedCoordinatorTests.swift | 66 ++--- 4 files changed, 430 insertions(+), 341 deletions(-) create mode 100644 CLI/CodexTeamsApprovalBridge.swift diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift new file mode 100644 index 000000000000..26d041a6b3ed --- /dev/null +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -0,0 +1,362 @@ +import Foundation + +struct CodexTeamsApprovalBridgeError: Error, CustomStringConvertible { + let description: String +} + +enum CodexTeamsApprovalBridge { + static func feedEvent( + method: String, + requestId: Any, + params: [String: Any], + workspaceId: String, + relatedItem: [String: Any]? = nil + ) -> [String: Any] { + let threadId = stringValue(in: params, keys: ["threadId", "thread_id"]) + ?? stringValue(in: params, keys: ["threadID", "thread_id"]) + ?? "unknown" + let turnId = stringValue(in: params, keys: ["turnId", "turn_id"]) + let itemId = stringValue(in: params, keys: ["approvalId", "approval_id", "itemId", "item_id"]) + ?? requestIdString(requestId) + let cwd = stringValue(in: params, keys: ["cwd"]) + let reason = stringValue(in: params, keys: ["reason"]) + let command = stringValue(in: params, keys: ["command"]) + let toolName: String + switch method { + case "item/fileChange/requestApproval": + toolName = "Write" + case "item/permissions/requestApproval": + toolName = "request_permissions" + default: + toolName = "Bash" + } + var toolInput: [String: Any] = [ + "app_server_method": method, + "request_id": requestIdString(requestId), + "item_id": itemId, + "approval_params": params + ] + if let turnId { toolInput["turn_id"] = turnId } + if let reason { toolInput["reason"] = reason } + if let command { toolInput["command"] = command } + if let cwd { toolInput["cwd"] = cwd } + if let approvalId = stringValue(in: params, keys: ["approvalId", "approval_id"]) { + toolInput["approval_id"] = approvalId + } + if let grantRoot = params["grantRoot"] ?? params["grant_root"] { + toolInput["grant_root"] = grantRoot + } + if let available = params["availableDecisions"] ?? params["available_decisions"] { + toolInput["available_decisions"] = decisionNames(available) + } + if let permissions = params["permissions"] { + toolInput["permissions"] = permissions + } + if let networkApprovalContext = params["networkApprovalContext"] ?? params["network_approval_context"] { + toolInput["network_approval_context"] = networkApprovalContext + } + if let additionalPermissions = params["additionalPermissions"] ?? params["additional_permissions"] { + toolInput["additional_permissions"] = additionalPermissions + } + if let commandActions = params["commandActions"] ?? params["command_actions"] { + toolInput["command_actions"] = commandActions + } + if let proposed = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { + toolInput["proposed_execpolicy_amendment"] = proposed + } + if let proposed = params["proposedNetworkPolicyAmendments"] ?? params["proposed_network_policy_amendments"] { + toolInput["proposed_network_policy_amendments"] = proposed + } + if let relatedItem { + toolInput["related_item"] = relatedItem + if command == nil, + let relatedCommand = relatedItem["command"] as? String { + toolInput["command"] = relatedCommand + } + if cwd == nil, + let relatedCwd = relatedItem["cwd"] as? String { + toolInput["cwd"] = relatedCwd + } + } + + var context: [String: Any] = [ + "permissionMode": "codex app-server" + ] + if let reason { + context["assistantPreamble"] = reason + } + if let command { + context["toolSummary"] = command + } + + var event: [String: Any] = [ + "session_id": "codex-\(threadId)", + "hook_event_name": "PermissionRequest", + "_source": "codex", + "workspace_id": workspaceId, + "tool_name": toolName, + "tool_input": toolInput, + "context": context, + "_opencode_request_id": "codex-app-server-\(itemId)" + ] + if let cwd { event["cwd"] = cwd } + return event + } + + static func permissionMode(fromFeedPushResponse response: [String: Any]) -> String? { + guard (response["status"] as? String) == "resolved", + let decision = response["decision"] as? [String: Any], + (decision["kind"] as? String) == "permission", + let mode = decision["mode"] as? String + else { return nil } + return mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + static func appServerApprovalResponse( + method: String, + params: [String: Any], + mode: String + ) -> [String: Any]? { + switch method { + case "item/commandExecution/requestApproval": + return ["decision": commandApprovalDecision(params: params, mode: mode)] + case "item/fileChange/requestApproval": + return ["decision": fileChangeApprovalDecision(params: params, mode: mode)] + case "item/permissions/requestApproval": + return permissionsApprovalResponse(params: params, mode: mode) + default: + return nil + } + } + + static func approvalItemSnapshot(_ item: [String: Any]) -> [String: Any] { + var snapshot: [String: Any] = [:] + for key in ["id", "type", "threadId", "thread_id", "turnId", "turn_id", "command", "cwd", "path", "status"] { + if let value = item[key], + let bounded = boundedApprovalItemValue(value) { + snapshot[key] = bounded + } + } + if let changes = item["changes"] as? [[String: Any]] { + snapshot["changes"] = changes.prefix(20).map { change in + var changeSnapshot: [String: Any] = [:] + for key in ["path", "kind", "type", "status"] { + if let value = change[key], + let bounded = boundedApprovalItemValue(value) { + changeSnapshot[key] = bounded + } + } + return changeSnapshot + } + } + return snapshot + } + + static func resolvedWorkingDirectory( + commandArgs: [String], + baseDirectory: String + ) -> String? { + let valueOptions: Set = ["-C", "--cd", "--cwd"] + let optionPrefixes = valueOptions.map { "\($0)=" } + var index = 0 + var requested: String? + while index < commandArgs.count { + let arg = commandArgs[index] + if arg == "--" { break } + if valueOptions.contains(arg), index + 1 < commandArgs.count { + requested = commandArgs[index + 1] + index += 2 + continue + } + if let prefix = optionPrefixes.first(where: { arg.hasPrefix($0) }) { + requested = String(arg.dropFirst(prefix.count)) + } + index += 1 + } + guard let requested = requested?.trimmingCharacters(in: .whitespacesAndNewlines), + !requested.isEmpty else { + return nil + } + let expanded = (requested as NSString).expandingTildeInPath + if expanded.hasPrefix("/") { + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + return URL( + fileURLWithPath: expanded, + relativeTo: URL(fileURLWithPath: baseDirectory, isDirectory: true) + ).standardizedFileURL.path + } + + static func validateWorkingDirectory( + commandArgs: [String], + baseDirectory: String + ) throws { + guard let cwd = resolvedWorkingDirectory( + commandArgs: commandArgs, + baseDirectory: baseDirectory + ) else { + return + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: cwd, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw CodexTeamsApprovalBridgeError(description: "cmux codex-teams cwd does not exist: \(cwd)") + } + } + + static func feedSourceSupportsPersistentPermissionModes(_ source: String) -> Bool { + source != "hermes-agent" + } + + static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { + source != "codex" && source != "claude" && source != "hermes-agent" + } + + static func requestIdString(_ requestId: Any) -> String { + if let string = requestId as? String { + return string + } + if let number = requestId as? NSNumber { + return number.stringValue + } + return String(describing: requestId) + } + + static func stringValue(in object: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = object[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } else if let value = object[key] as? NSNumber { + return value.stringValue + } + } + return nil + } + + private static func commandApprovalDecision(params: [String: Any], mode: String) -> Any { + if mode == "deny" { return rejectApprovalDecision(params: params) } + if mode == "all" || mode == "bypass", + let decision = commandApprovalAmendmentDecision(params: params) { + return decision + } + if modeRequestsPersistentApproval(mode), + decisionAvailableOrUnspecified("acceptForSession", params: params) { + return "acceptForSession" + } + if modeRequestsPersistentApproval(mode), + let decision = commandApprovalAmendmentDecision(params: params) { + return decision + } + if decisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } + if let decision = commandApprovalAmendmentDecision(params: params) { + return decision + } + return rejectApprovalDecision(params: params) + } + + private static func commandApprovalAmendmentDecision(params: [String: Any]) -> Any? { + if decisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", params: params), + let amendment = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { + return [ + "acceptWithExecpolicyAmendment": [ + "execpolicy_amendment": amendment + ] + ] + } + if decisionAvailableOrUnspecified("applyNetworkPolicyAmendment", params: params), + let amendments = (params["proposedNetworkPolicyAmendments"] as? [Any]) + ?? (params["proposed_network_policy_amendments"] as? [Any]), + let amendment = amendments.first { + return [ + "applyNetworkPolicyAmendment": [ + "network_policy_amendment": amendment + ] + ] + } + return nil + } + + private static func boundedApprovalItemValue(_ value: Any) -> Any? { + if let string = value as? String { + let limit = 4_096 + guard string.count > limit else { return string } + let index = string.index(string.startIndex, offsetBy: limit) + return String(string[.. String { + if mode == "deny" { return rejectApprovalDecision(params: params) } + if modeRequestsPersistentApproval(mode) + && decisionAvailableOrUnspecified("acceptForSession", params: params) { + return "acceptForSession" + } + if decisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } + return rejectApprovalDecision(params: params) + } + + private static func permissionsApprovalResponse(params: [String: Any], mode: String) -> [String: Any] { + if mode == "deny" { + return [ + "permissions": [String: Any](), + "scope": "turn" + ] + } + return [ + "permissions": params["permissions"] ?? [String: Any](), + "scope": modeRequestsPersistentApproval(mode) ? "session" : "turn" + ] + } + + private static func modeRequestsPersistentApproval(_ mode: String) -> Bool { + mode == "always" || mode == "all" || mode == "bypass" + } + + private static func availableDecisions(_ params: [String: Any]) -> Set { + guard let raw = params["availableDecisions"] ?? params["available_decisions"] else { + return [] + } + return Set(decisionNames(raw)) + } + + private static func decisionAvailableOrUnspecified(_ decision: String, params: [String: Any]) -> Bool { + guard params["availableDecisions"] != nil || params["available_decisions"] != nil else { + return true + } + return availableDecisions(params).contains(decision) + } + + private static func rejectApprovalDecision(params: [String: Any]) -> String { + let available = availableDecisions(params) + if available.contains("decline") || available.isEmpty { + return "decline" + } + if available.contains("cancel") { + return "cancel" + } + return "decline" + } + + private static func decisionNames(_ raw: Any) -> [String] { + let values = raw as? [Any] ?? [] + return values.compactMap { value in + if let string = value as? String { + return string + } + if let object = value as? [String: Any], + let key = object.keys.first { + return key + } + return nil + } + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0b8d3cbe4e8e..439278524ffa 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18655,104 +18655,17 @@ struct CMUXCLI { workspaceId: String, relatedItem: [String: Any]? = nil ) -> [String: Any] { - let threadId = stringValue(in: params, keys: ["threadId", "thread_id"]) - ?? stringValue(in: params, keys: ["threadID", "thread_id"]) - ?? "unknown" - let turnId = stringValue(in: params, keys: ["turnId", "turn_id"]) - let itemId = stringValue(in: params, keys: ["approvalId", "approval_id", "itemId", "item_id"]) - ?? requestIdString(requestId) - let cwd = stringValue(in: params, keys: ["cwd"]) - let reason = stringValue(in: params, keys: ["reason"]) - let command = stringValue(in: params, keys: ["command"]) - let toolName: String - switch method { - case "item/fileChange/requestApproval": - toolName = "Write" - case "item/permissions/requestApproval": - toolName = "request_permissions" - default: - toolName = "Bash" - } - var toolInput: [String: Any] = [ - "app_server_method": method, - "request_id": requestIdString(requestId), - "item_id": itemId, - "approval_params": params - ] - if let turnId { toolInput["turn_id"] = turnId } - if let reason { toolInput["reason"] = reason } - if let command { toolInput["command"] = command } - if let cwd { toolInput["cwd"] = cwd } - if let approvalId = stringValue(in: params, keys: ["approvalId", "approval_id"]) { - toolInput["approval_id"] = approvalId - } - if let grantRoot = params["grantRoot"] ?? params["grant_root"] { - toolInput["grant_root"] = grantRoot - } - if let available = params["availableDecisions"] ?? params["available_decisions"] { - toolInput["available_decisions"] = codexTeamsDecisionNames(available) - } - if let permissions = params["permissions"] { - toolInput["permissions"] = permissions - } - if let networkApprovalContext = params["networkApprovalContext"] ?? params["network_approval_context"] { - toolInput["network_approval_context"] = networkApprovalContext - } - if let additionalPermissions = params["additionalPermissions"] ?? params["additional_permissions"] { - toolInput["additional_permissions"] = additionalPermissions - } - if let commandActions = params["commandActions"] ?? params["command_actions"] { - toolInput["command_actions"] = commandActions - } - if let proposed = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { - toolInput["proposed_execpolicy_amendment"] = proposed - } - if let proposed = params["proposedNetworkPolicyAmendments"] ?? params["proposed_network_policy_amendments"] { - toolInput["proposed_network_policy_amendments"] = proposed - } - if let relatedItem { - toolInput["related_item"] = relatedItem - if command == nil, - let relatedCommand = relatedItem["command"] as? String { - toolInput["command"] = relatedCommand - } - if cwd == nil, - let relatedCwd = relatedItem["cwd"] as? String { - toolInput["cwd"] = relatedCwd - } - } - - var context: [String: Any] = [ - "permissionMode": "codex app-server" - ] - if let reason { - context["assistantPreamble"] = reason - } - if let command { - context["toolSummary"] = command - } - - var event: [String: Any] = [ - "session_id": "codex-\(threadId)", - "hook_event_name": "PermissionRequest", - "_source": "codex", - "workspace_id": workspaceId, - "tool_name": toolName, - "tool_input": toolInput, - "context": context, - "_opencode_request_id": "codex-app-server-\(itemId)" - ] - if let cwd { event["cwd"] = cwd } - return event + CodexTeamsApprovalBridge.feedEvent( + method: method, + requestId: requestId, + params: params, + workspaceId: workspaceId, + relatedItem: relatedItem + ) } static func codexTeamsPermissionMode(fromFeedPushResponse response: [String: Any]) -> String? { - guard (response["status"] as? String) == "resolved", - let decision = response["decision"] as? [String: Any], - (decision["kind"] as? String) == "permission", - let mode = decision["mode"] as? String - else { return nil } - return mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + CodexTeamsApprovalBridge.permissionMode(fromFeedPushResponse: response) } static func codexTeamsAppServerApprovalResponse( @@ -18760,187 +18673,23 @@ struct CMUXCLI { params: [String: Any], mode: String ) -> [String: Any]? { - switch method { - case "item/commandExecution/requestApproval": - return ["decision": codexTeamsCommandApprovalDecision(params: params, mode: mode)] - case "item/fileChange/requestApproval": - return ["decision": codexTeamsFileChangeApprovalDecision(params: params, mode: mode)] - case "item/permissions/requestApproval": - return codexTeamsPermissionsApprovalResponse(params: params, mode: mode) - default: - return nil - } - } - - static func codexTeamsCommandApprovalDecision(params: [String: Any], mode: String) -> Any { - if mode == "deny" { return codexTeamsRejectApprovalDecision(params: params) } - if mode == "all" || mode == "bypass", - let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { - return decision - } - if codexTeamsModeRequestsPersistentApproval(mode), - codexTeamsDecisionAvailableOrUnspecified("acceptForSession", params: params) { - return "acceptForSession" - } - if codexTeamsModeRequestsPersistentApproval(mode), - let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { - return decision - } - if codexTeamsDecisionAvailableOrUnspecified("accept", params: params) { - return "accept" - } - if let decision = codexTeamsCommandApprovalAmendmentDecision(params: params) { - return decision - } - return codexTeamsRejectApprovalDecision(params: params) - } - - static func codexTeamsCommandApprovalAmendmentDecision(params: [String: Any]) -> Any? { - if codexTeamsDecisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", params: params), - let amendment = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { - return [ - "acceptWithExecpolicyAmendment": [ - "execpolicy_amendment": amendment - ] - ] - } - if codexTeamsDecisionAvailableOrUnspecified("applyNetworkPolicyAmendment", params: params), - let amendments = (params["proposedNetworkPolicyAmendments"] as? [Any]) - ?? (params["proposed_network_policy_amendments"] as? [Any]), - let amendment = amendments.first { - return [ - "applyNetworkPolicyAmendment": [ - "network_policy_amendment": amendment - ] - ] - } - return nil + CodexTeamsApprovalBridge.appServerApprovalResponse( + method: method, + params: params, + mode: mode + ) } static func codexTeamsApprovalItemSnapshot(_ item: [String: Any]) -> [String: Any] { - var snapshot: [String: Any] = [:] - for key in ["id", "type", "threadId", "thread_id", "turnId", "turn_id", "command", "cwd", "path", "status"] { - if let value = item[key], - let bounded = codexTeamsBoundedApprovalItemValue(value) { - snapshot[key] = bounded - } - } - if let changes = item["changes"] as? [[String: Any]] { - snapshot["changes"] = changes.prefix(20).map { change in - var changeSnapshot: [String: Any] = [:] - for key in ["path", "kind", "type", "status"] { - if let value = change[key], - let bounded = codexTeamsBoundedApprovalItemValue(value) { - changeSnapshot[key] = bounded - } - } - return changeSnapshot - } - } - return snapshot - } - - static func codexTeamsBoundedApprovalItemValue(_ value: Any) -> Any? { - if let string = value as? String { - let limit = 4_096 - guard string.count > limit else { return string } - let index = string.index(string.startIndex, offsetBy: limit) - return String(string[.. String { - if mode == "deny" { return codexTeamsRejectApprovalDecision(params: params) } - if codexTeamsModeRequestsPersistentApproval(mode) - && codexTeamsDecisionAvailableOrUnspecified("acceptForSession", params: params) { - return "acceptForSession" - } - if codexTeamsDecisionAvailableOrUnspecified("accept", params: params) { - return "accept" - } - return codexTeamsRejectApprovalDecision(params: params) - } - - static func codexTeamsPermissionsApprovalResponse(params: [String: Any], mode: String) -> [String: Any] { - if mode == "deny" { - return [ - "permissions": [String: Any](), - "scope": "turn" - ] - } - return [ - "permissions": params["permissions"] ?? [String: Any](), - "scope": codexTeamsModeRequestsPersistentApproval(mode) ? "session" : "turn" - ] - } - - static func codexTeamsModeRequestsPersistentApproval(_ mode: String) -> Bool { - mode == "always" || mode == "all" || mode == "bypass" - } - - static func codexTeamsAvailableDecisions(_ params: [String: Any]) -> Set { - guard let raw = params["availableDecisions"] ?? params["available_decisions"] else { - return [] - } - return Set(codexTeamsDecisionNames(raw)) - } - - static func codexTeamsDecisionAvailableOrUnspecified(_ decision: String, params: [String: Any]) -> Bool { - guard params["availableDecisions"] != nil || params["available_decisions"] != nil else { - return true - } - return codexTeamsAvailableDecisions(params).contains(decision) - } - - static func codexTeamsRejectApprovalDecision(params: [String: Any]) -> String { - let available = codexTeamsAvailableDecisions(params) - if available.contains("decline") || available.isEmpty { - return "decline" - } - if available.contains("cancel") { - return "cancel" - } - return "decline" - } - - static func codexTeamsDecisionNames(_ raw: Any) -> [String] { - let values = raw as? [Any] ?? [] - return values.compactMap { value in - if let string = value as? String { - return string - } - if let object = value as? [String: Any], - let key = object.keys.first { - return key - } - return nil - } + CodexTeamsApprovalBridge.approvalItemSnapshot(item) } static func requestIdString(_ requestId: Any) -> String { - if let string = requestId as? String { - return string - } - if let number = requestId as? NSNumber { - return number.stringValue - } - return String(describing: requestId) + CodexTeamsApprovalBridge.requestIdString(requestId) } static func stringValue(in object: [String: Any], keys: [String]) -> String? { - for key in keys { - if let value = object[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } else if let value = object[key] as? NSNumber { - return value.stringValue - } - } - return nil + CodexTeamsApprovalBridge.stringValue(in: object, keys: keys) } private static func codexTeamsThreadCanResume(appServerURL: String, threadId: String) -> Bool { @@ -19332,51 +19081,23 @@ struct CMUXCLI { commandArgs: [String], baseDirectory: String ) -> String? { - let valueOptions: Set = ["-C", "--cd", "--cwd"] - let optionPrefixes = valueOptions.map { "\($0)=" } - var index = 0 - var requested: String? - while index < commandArgs.count { - let arg = commandArgs[index] - if arg == "--" { break } - if valueOptions.contains(arg), index + 1 < commandArgs.count { - requested = commandArgs[index + 1] - index += 2 - continue - } - if let prefix = optionPrefixes.first(where: { arg.hasPrefix($0) }) { - requested = String(arg.dropFirst(prefix.count)) - } - index += 1 - } - guard let requested = requested?.trimmingCharacters(in: .whitespacesAndNewlines), - !requested.isEmpty else { - return nil - } - let expanded = (requested as NSString).expandingTildeInPath - if expanded.hasPrefix("/") { - return URL(fileURLWithPath: expanded).standardizedFileURL.path - } - return URL( - fileURLWithPath: expanded, - relativeTo: URL(fileURLWithPath: baseDirectory, isDirectory: true) - ).standardizedFileURL.path + CodexTeamsApprovalBridge.resolvedWorkingDirectory( + commandArgs: commandArgs, + baseDirectory: baseDirectory + ) } static func validateCodexTeamsWorkingDirectory( commandArgs: [String], baseDirectory: String ) throws { - guard let cwd = codexTeamsResolvedWorkingDirectory( - commandArgs: commandArgs, - baseDirectory: baseDirectory - ) else { - return - } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: cwd, isDirectory: &isDirectory), - isDirectory.boolValue else { - throw CLIError(message: "cmux codex-teams cwd does not exist: \(cwd)") + do { + try CodexTeamsApprovalBridge.validateWorkingDirectory( + commandArgs: commandArgs, + baseDirectory: baseDirectory + ) + } catch let error as CodexTeamsApprovalBridgeError { + throw CLIError(message: error.description) } } @@ -30635,11 +30356,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } static func feedSourceSupportsPersistentPermissionModes(_ source: String) -> Bool { - source != "hermes-agent" + CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes(source) } static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { - source != "codex" && source != "claude" && source != "hermes-agent" + CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions(source) } private func resolveFeedTUIItem( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1c58baff2c09..cf8d0ce9338c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -205,6 +205,8 @@ 7D5DA4DC51454ECDBF9AC978 /* CmuxWebViewMouseNavigationButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F90FAF3FD44F11BF547BE9 /* CmuxWebViewMouseNavigationButtonTests.swift */; }; E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */; }; AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; + C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; + C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */; }; C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */; }; @@ -880,6 +882,7 @@ D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewDragRoutingTests.swift; sourceTree = ""; }; 43F90FAF3FD44F11BF547BE9 /* CmuxWebViewMouseNavigationButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewMouseNavigationButtonTests.swift; sourceTree = ""; }; E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWorkspaceDefinition.swift; sourceTree = ""; }; + C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTeamsApprovalBridge.swift; sourceTree = ""; }; C4041001000000000000001A /* CommandClickFileOpenRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClickFileOpenRouter.swift; sourceTree = ""; }; C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIdentifierClipboardUITests.swift; sourceTree = ""; }; C0DEC0DE000000000000F201 /* CommandPaletteNucleoFFILibrarySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteNucleoFFILibrarySupport.swift; sourceTree = ""; }; @@ -1872,6 +1875,7 @@ isa = PBXGroup; children = ( B9000001A1B2C3D4E5F60719 /* cmux.swift */, + C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */, FEEDC1A50000000000000002 /* FeedEventClassifier.swift */, B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */, C510C1E00000000000000001 /* SocketOperationTelemetry.swift */, @@ -2827,6 +2831,7 @@ B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */, B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */, B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, + C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */, FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, C47110020000000000000003 /* ProcessPipeReader.swift in Sources */, @@ -2932,6 +2937,7 @@ C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */, D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */, 7D5DA4DC51454ECDBF9AC978 /* CmuxWebViewMouseNavigationButtonTests.swift in Sources */, + C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */, C0DEC0DE000000000000F202 /* CommandPaletteNucleoFFILibrarySupport.swift in Sources */, C0DEC0DE000000000000F102 /* CommandPaletteNucleoFFITests.swift in Sources */, C0DEC0DE000000000000F204 /* CommandPaletteNucleoFixtures.swift in Sources */, diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 89d68de3c4eb..ef56d63ee235 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -14,19 +14,19 @@ struct FeedCoordinatorTests { let base = "/tmp/cmux-base" #expect( - CMUXCLI.codexTeamsResolvedWorkingDirectory( + CodexTeamsApprovalBridge.resolvedWorkingDirectory( commandArgs: ["-C", "child", "prompt"], baseDirectory: base ) == "/tmp/cmux-base/child" ) #expect( - CMUXCLI.codexTeamsResolvedWorkingDirectory( + CodexTeamsApprovalBridge.resolvedWorkingDirectory( commandArgs: ["--cwd=/tmp/cmux-review", "--cd", "/tmp/cmux-final"], baseDirectory: base ) == "/tmp/cmux-final" ) #expect( - CMUXCLI.codexTeamsResolvedWorkingDirectory( + CodexTeamsApprovalBridge.resolvedWorkingDirectory( commandArgs: ["--", "-C", "/tmp/inside-prompt"], baseDirectory: base ) == nil @@ -40,7 +40,7 @@ struct FeedCoordinatorTests { defer { try? FileManager.default.removeItem(at: existing) } do { - try CMUXCLI.validateCodexTeamsWorkingDirectory( + try CodexTeamsApprovalBridge.validateWorkingDirectory( commandArgs: ["-C", existing.path], baseDirectory: "/tmp" ) @@ -49,7 +49,7 @@ struct FeedCoordinatorTests { } do { - try CMUXCLI.validateCodexTeamsWorkingDirectory( + try CodexTeamsApprovalBridge.validateWorkingDirectory( commandArgs: ["-C", existing.appendingPathComponent("missing").path], baseDirectory: "/tmp" ) @@ -62,27 +62,27 @@ struct FeedCoordinatorTests { @Test func claudePermissionActionPolicyKeepsBypassUserOwned() { #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .claude)) #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .claude)) - #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("claude")) - #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("claude")) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("claude")) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("claude")) #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .codex)) #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .codex)) - #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("codex")) - #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("codex")) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("codex")) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("codex")) #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) #expect(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) - #expect(CMUXCLI.feedSourceSupportsPersistentPermissionModes("opencode")) - #expect(CMUXCLI.feedSourceSupportsBypassPermissions("opencode")) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("opencode")) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("opencode")) #expect(!FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .hermesAgent)) #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) - #expect(!CMUXCLI.feedSourceSupportsPersistentPermissionModes("hermes-agent")) - #expect(!CMUXCLI.feedSourceSupportsBypassPermissions("hermes-agent")) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("hermes-agent")) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("hermes-agent")) } @Test func codexAppServerApprovalBuildsActionableFeedEvent() throws { - let event = CMUXCLI.codexTeamsFeedEvent( + let event = CodexTeamsApprovalBridge.feedEvent( method: "item/commandExecution/requestApproval", requestId: 41, params: [ @@ -144,7 +144,7 @@ struct FeedCoordinatorTests { "write": ["/tmp/write"] ] ] - let event = CMUXCLI.codexTeamsFeedEvent( + let event = CodexTeamsApprovalBridge.feedEvent( method: "item/permissions/requestApproval", requestId: "permissions-request", params: [ @@ -167,7 +167,7 @@ struct FeedCoordinatorTests { #expect(toolInput["permissions"] != nil) let once = try #require( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "once" @@ -177,7 +177,7 @@ struct FeedCoordinatorTests { #expect(once["permissions"] != nil) let always = try #require( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "always" @@ -186,7 +186,7 @@ struct FeedCoordinatorTests { #expect(always["scope"] as? String == "session") let deny = try #require( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/permissions/requestApproval", params: ["permissions": permissions], mode: "deny" @@ -202,26 +202,26 @@ struct FeedCoordinatorTests { ] #expect( - CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: [ + CodexTeamsApprovalBridge.permissionMode(fromFeedPushResponse: [ "status": "resolved", "decision": ["kind": "permission", "mode": "always"] ]) == "always" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: params, mode: "always" )?["decision"] as? String == "acceptForSession" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [:], mode: "always" )?["decision"] as? String == "acceptForSession" ) - let amendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + let amendmentDecision = CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ "availableDecisions": [["acceptWithExecpolicyAmendment": [:]]], @@ -230,7 +230,7 @@ struct FeedCoordinatorTests { mode: "always" )?["decision"] as? [String: Any] #expect(amendmentDecision?["acceptWithExecpolicyAmendment"] != nil) - let onceAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + let onceAmendmentDecision = CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ "availableDecisions": [["applyNetworkPolicyAmendment": [:]]], @@ -239,7 +239,7 @@ struct FeedCoordinatorTests { mode: "once" )?["decision"] as? [String: Any] #expect(onceAmendmentDecision?["applyNetworkPolicyAmendment"] != nil) - let unspecifiedAmendmentDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + let unspecifiedAmendmentDecision = CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] @@ -255,58 +255,58 @@ struct FeedCoordinatorTests { "proposedExecpolicyAmendment": [["kind": "prefix", "value": "npm test"]] ] #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: mixedParams, mode: "always" )?["decision"] as? String == "acceptForSession" ) - let allToolsDecision = CMUXCLI.codexTeamsAppServerApprovalResponse( + let allToolsDecision = CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: mixedParams, mode: "all" )?["decision"] as? [String: Any] #expect(allToolsDecision?["acceptWithExecpolicyAmendment"] != nil) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: ["availableDecisions": ["decline"]], mode: "once" )?["decision"] as? String == "decline" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/fileChange/requestApproval", params: [:], mode: "once" )?["decision"] as? String == "accept" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/fileChange/requestApproval", params: [:], mode: "always" )?["decision"] as? String == "acceptForSession" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/fileChange/requestApproval", params: ["availableDecisions": ["accept", "decline"]], mode: "always" )?["decision"] as? String == "accept" ) #expect( - CMUXCLI.codexTeamsAppServerApprovalResponse( + CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: params, mode: "deny" )?["decision"] as? String == "decline" ) - #expect(CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: ["status": "timed_out"]) == nil) + #expect(CodexTeamsApprovalBridge.permissionMode(fromFeedPushResponse: ["status": "timed_out"]) == nil) } @Test func codexApprovalItemSnapshotStripsLargePayloads() throws { - let snapshot = CMUXCLI.codexTeamsApprovalItemSnapshot([ + let snapshot = CodexTeamsApprovalBridge.approvalItemSnapshot([ "id": "call-1", "type": "commandExecution", "command": String(repeating: "x", count: 5_000), From 9c53e3932d8bce0065845b955d81a2178426518f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:04:49 -0700 Subject: [PATCH 16/29] Serialize Feed coordinator tests --- cmuxTests/FeedCoordinatorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ef56d63ee235..a2d244b917f0 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -8,7 +8,7 @@ import CMUXWorkstream @testable import cmux #endif -@Suite("Feed coordinator") +@Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" From bda56917696bb90f5d33baec944217694ef86073 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:16:52 -0700 Subject: [PATCH 17/29] Gate Codex persistent approval modes per request --- CLI/CodexTeamsApprovalBridge.swift | 89 +++++++++++++++++++ CLI/cmux.swift | 46 ++++++++-- Sources/Feed/FeedPanelView.swift | 5 +- Sources/Feed/FeedPermissionActionPolicy.swift | 88 ++++++++++++++++++ cmuxTests/FeedCoordinatorTests.swift | 31 ++++++- 5 files changed, 251 insertions(+), 8 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 26d041a6b3ed..6d9befb3311e 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -208,6 +208,18 @@ enum CodexTeamsApprovalBridge { source != "hermes-agent" } + static func feedSourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + guard feedSourceSupportsPersistentPermissionModes(source) else { return false } + guard source == "codex" else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsAlways + } + + static func feedSourceSupportsAllPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + guard feedSourceSupportsPersistentPermissionModes(source) else { return false } + guard source == "codex" else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsAll + } + static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { source != "codex" && source != "claude" && source != "hermes-agent" } @@ -240,6 +252,9 @@ enum CodexTeamsApprovalBridge { let decision = commandApprovalAmendmentDecision(params: params) { return decision } + if mode == "all" || mode == "bypass" { + return rejectApprovalDecision(params: params) + } if modeRequestsPersistentApproval(mode), decisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" @@ -248,6 +263,12 @@ enum CodexTeamsApprovalBridge { let decision = commandApprovalAmendmentDecision(params: params) { return decision } + if modeRequestsPersistentApproval(mode) { + return rejectApprovalDecision(params: params) + } + guard mode == "once" else { + return rejectApprovalDecision(params: params) + } if decisionAvailableOrUnspecified("accept", params: params) { return "accept" } @@ -298,6 +319,12 @@ enum CodexTeamsApprovalBridge { && decisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" } + if modeRequestsPersistentApproval(mode) { + return rejectApprovalDecision(params: params) + } + guard mode == "once" else { + return rejectApprovalDecision(params: params) + } if decisionAvailableOrUnspecified("accept", params: params) { return "accept" } @@ -311,6 +338,12 @@ enum CodexTeamsApprovalBridge { "scope": "turn" ] } + guard mode == "once" || modeRequestsPersistentApproval(mode) else { + return [ + "permissions": [String: Any](), + "scope": "turn" + ] + } return [ "permissions": params["permissions"] ?? [String: Any](), "scope": modeRequestsPersistentApproval(mode) ? "session" : "turn" @@ -359,4 +392,60 @@ enum CodexTeamsApprovalBridge { return nil } } + + private static func codexCapabilities(toolInputJSON: String?) -> CodexPermissionCapabilities { + guard let toolInputJSON, + let data = toolInputJSON.data(using: .utf8), + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { + return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + } + + let method = object["app_server_method"] as? String + let decisions = codexAvailableDecisions(in: object) + let acceptsSession = decisions?.contains("acceptForSession") ?? true + switch method { + case "item/permissions/requestApproval": + return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + case "item/commandExecution/requestApproval": + return CodexPermissionCapabilities( + supportsAlways: acceptsSession, + supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) + ) + case "item/fileChange/requestApproval": + return CodexPermissionCapabilities(supportsAlways: acceptsSession, supportsAll: false) + default: + return CodexPermissionCapabilities(supportsAlways: acceptsSession, supportsAll: false) + } + } + + private static func codexSupportsAmendmentDecision(object: [String: Any], decisions: Set?) -> Bool { + if let amendment = object["proposed_execpolicy_amendment"], + codexDecisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", decisions: decisions), + !(amendment is NSNull) { + return true + } + if let amendments = object["proposed_network_policy_amendments"] as? [Any], + !amendments.isEmpty, + codexDecisionAvailableOrUnspecified("applyNetworkPolicyAmendment", decisions: decisions) { + return true + } + return false + } + + private static func codexAvailableDecisions(in object: [String: Any]) -> Set? { + guard let raw = object["available_decisions"] ?? object["availableDecisions"] else { + return nil + } + return Set(decisionNames(raw)) + } + + private static func codexDecisionAvailableOrUnspecified(_ decision: String, decisions: Set?) -> Bool { + decisions?.contains(decision) ?? true + } +} + +private struct CodexPermissionCapabilities { + let supportsAlways: Bool + let supportsAll: Bool } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f7f797d3fe2e..91ab83d93455 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29559,6 +29559,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let createdAt: Date? let title: String let detail: String + let toolInputJSON: String let defaultMode: String? let questionMultiSelect: Bool let questionOptions: [FeedTUIOption] @@ -29608,6 +29609,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { createdAt: createdAt, title: title, detail: detail, + toolInputJSON: (dict["tool_input"] as? String) ?? "", defaultMode: dict["default_mode"] as? String, questionMultiSelect: (dict["question_multi_select"] as? Bool) ?? false, questionOptions: options, @@ -30308,11 +30310,25 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { guard item.canResolve else { return "Resolved or informational item" } switch item.kind { case "permissionRequest": - if !feedTUISourceSupportsPersistentPermissionModes(item.source) { + let supportsAlways = feedTUISourceSupportsAlwaysPermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ) + let supportsAll = feedTUISourceSupportsAllPermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ) + if !supportsAlways && !supportsAll { return "Permission: Enter/o once, d deny" } if !feedTUISourceSupportsBypassPermissions(item.source) { - return "Permission: Enter/o once, a always, l all tools, d deny" + if supportsAlways && supportsAll { + return "Permission: Enter/o once, a always, l all tools, d deny" + } + if supportsAlways { + return "Permission: Enter/o once, a always, d deny" + } + return "Permission: Enter/o once, l all tools, d deny" } return "Permission: Enter/o once, a always, l all tools, b bypass, d deny" case "exitPlan": @@ -30339,8 +30355,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } - private func feedTUISourceSupportsPersistentPermissionModes(_ source: String) -> Bool { - CMUXCLI.feedSourceSupportsPersistentPermissionModes(source) + private func feedTUISourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CMUXCLI.feedSourceSupportsAlwaysPermissionMode(source, toolInputJSON: toolInputJSON) + } + + private func feedTUISourceSupportsAllPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CMUXCLI.feedSourceSupportsAllPermissionMode(source, toolInputJSON: toolInputJSON) } private func feedTUISourceSupportsBypassPermissions(_ source: String) -> Bool { @@ -30351,6 +30371,14 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes(source) } + static func feedSourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode(source, toolInputJSON: toolInputJSON) + } + + static func feedSourceSupportsAllPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode(source, toolInputJSON: toolInputJSON) + } + static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions(source) } @@ -30372,9 +30400,15 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { switch key { case .enter, .once: mode = "once" - case .always where feedTUISourceSupportsPersistentPermissionModes(item.source): + case .always where feedTUISourceSupportsAlwaysPermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ): mode = "always" - case .all where feedTUISourceSupportsPersistentPermissionModes(item.source): + case .all where feedTUISourceSupportsAllPermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ): mode = "all" case .bypass where feedTUISourceSupportsBypassPermissions(item.source): mode = "bypass" diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 81bd2156d375..7122a3a970d1 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -1453,7 +1453,10 @@ private struct PermissionActionArea: View { onApprove(.once) } .accessibilityIdentifier("FeedPermissionAllowOnceButton") - if FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: source) { + if FeedPermissionActionPolicy.supportsAlwaysPermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { FeedButton(label: String(localized: "feed.permission.always", defaultValue: "Always Allow"), kind: .primary, size: .medium, fullWidth: true) { onActionRow() diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 215149227a9c..8cd9a3d97569 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -1,3 +1,4 @@ +import Foundation import CMUXWorkstream enum FeedPermissionActionPolicy { @@ -5,7 +6,94 @@ enum FeedPermissionActionPolicy { source != .hermesAgent } + static func supportsAlwaysPermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { + guard supportsPersistentPermissionModes(source: source) else { return false } + guard source == .codex else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsAlways + } + + static func supportsAllPermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { + guard supportsPersistentPermissionModes(source: source) else { return false } + guard source == .codex else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsAll + } + static func supportsBypassPermissions(source: WorkstreamSource) -> Bool { source != .codex && source != .claude && source != .hermesAgent } + + private static func codexCapabilities(toolInputJSON: String?) -> CodexPermissionCapabilities { + guard let toolInputJSON, + let data = toolInputJSON.data(using: .utf8), + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { + return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + } + + let method = object["app_server_method"] as? String + let decisions = codexAvailableDecisions(in: object) + let acceptsSession = decisions?.contains("acceptForSession") ?? true + switch method { + case "item/permissions/requestApproval": + return CodexPermissionCapabilities( + supportsAlways: true, + supportsAll: true + ) + case "item/commandExecution/requestApproval": + return CodexPermissionCapabilities( + supportsAlways: acceptsSession, + supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) + ) + case "item/fileChange/requestApproval": + return CodexPermissionCapabilities( + supportsAlways: acceptsSession, + supportsAll: false + ) + default: + return CodexPermissionCapabilities( + supportsAlways: acceptsSession, + supportsAll: false + ) + } + } + + private static func codexSupportsAmendmentDecision(object: [String: Any], decisions: Set?) -> Bool { + if let amendment = object["proposed_execpolicy_amendment"], + codexDecisionAvailableOrUnspecified("acceptWithExecpolicyAmendment", decisions: decisions), + !(amendment is NSNull) { + return true + } + if let amendments = object["proposed_network_policy_amendments"] as? [Any], + !amendments.isEmpty, + codexDecisionAvailableOrUnspecified("applyNetworkPolicyAmendment", decisions: decisions) { + return true + } + return false + } + + private static func codexAvailableDecisions(in object: [String: Any]) -> Set? { + guard let raw = object["available_decisions"] ?? object["availableDecisions"] else { + return nil + } + let values = raw as? [Any] ?? [] + return Set(values.compactMap { value in + if let string = value as? String { + return string + } + if let object = value as? [String: Any], + let key = object.keys.first { + return key + } + return nil + }) + } + + private static func codexDecisionAvailableOrUnspecified(_ decision: String, decisions: Set?) -> Bool { + decisions?.contains(decision) ?? true + } +} + +private struct CodexPermissionCapabilities { + let supportsAlways: Bool + let supportsAll: Bool } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index a2d244b917f0..aead50a5c617 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -70,6 +70,28 @@ struct FeedCoordinatorTests { #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("codex")) #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("codex")) + let codexOneShotOnly = #""" + {"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept","decline"]} + """# + #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) + #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexOneShotOnly)) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexOneShotOnly)) + + let codexSession = #""" + {"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept","acceptForSession","decline"]} + """# + #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexSession)) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexSession)) + + let codexAmendment = #""" + {"app_server_method":"item/commandExecution/requestApproval","available_decisions":[{"acceptWithExecpolicyAmendment":{}}],"proposed_execpolicy_amendment":[{"kind":"prefix","value":"npm test"}]} + """# + #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexAmendment)) + #expect(FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexAmendment)) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexAmendment)) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexAmendment)) + #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) #expect(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("opencode")) @@ -274,6 +296,13 @@ struct FeedCoordinatorTests { mode: "once" )?["decision"] as? String == "decline" ) + #expect( + CodexTeamsApprovalBridge.appServerApprovalResponse( + method: "item/commandExecution/requestApproval", + params: ["availableDecisions": ["accept", "decline"]], + mode: "always" + )?["decision"] as? String == "decline" + ) #expect( CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/fileChange/requestApproval", @@ -293,7 +322,7 @@ struct FeedCoordinatorTests { method: "item/fileChange/requestApproval", params: ["availableDecisions": ["accept", "decline"]], mode: "always" - )?["decision"] as? String == "accept" + )?["decision"] as? String == "decline" ) #expect( CodexTeamsApprovalBridge.appServerApprovalResponse( From 4e1898a149a9b9dd9b9f25806f947fb8bd6a82cb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:25:28 -0700 Subject: [PATCH 18/29] Keep Codex approval scopes explicit --- CLI/CodexTeamsApprovalBridge.swift | 3 --- Sources/Feed/FeedPanelView.swift | 11 +++++++++++ cmuxTests/FeedCoordinatorTests.swift | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 6d9befb3311e..3a5341fc7e0a 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -272,9 +272,6 @@ enum CodexTeamsApprovalBridge { if decisionAvailableOrUnspecified("accept", params: params) { return "accept" } - if let decision = commandApprovalAmendmentDecision(params: params) { - return decision - } return rejectApprovalDecision(params: params) } diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 7122a3a970d1..ff1821e6b87c 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -1464,6 +1464,17 @@ private struct PermissionActionArea: View { } .accessibilityIdentifier("FeedPermissionAlwaysAllowButton") } + if FeedPermissionActionPolicy.supportsAllPermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.all", defaultValue: "All tools"), + kind: .primary, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.all) + } + .accessibilityIdentifier("FeedPermissionAllToolsButton") + } if FeedPermissionActionPolicy.supportsBypassPermissions(source: source) { FeedButton(label: String(localized: "feed.permission.bypass", defaultValue: "Bypass"), kind: .destructive, size: .medium, fullWidth: true) { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index aead50a5c617..d83bd8c9dee7 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -259,8 +259,8 @@ struct FeedCoordinatorTests { "proposedNetworkPolicyAmendments": [["host": "example.com"]] ], mode: "once" - )?["decision"] as? [String: Any] - #expect(onceAmendmentDecision?["applyNetworkPolicyAmendment"] != nil) + )?["decision"] as? String + #expect(onceAmendmentDecision == "decline") let unspecifiedAmendmentDecision = CodexTeamsApprovalBridge.appServerApprovalResponse( method: "item/commandExecution/requestApproval", params: [ From 97888a4d9451af8a87b4bba42f96616b81532886 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:35:33 -0700 Subject: [PATCH 19/29] Avoid denial for unsupported Codex persistent replies --- CLI/CodexTeamsApprovalBridge.swift | 9 +++++++++ cmuxTests/FeedCoordinatorTests.swift | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 3a5341fc7e0a..e1bf87a1d95a 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -253,6 +253,9 @@ enum CodexTeamsApprovalBridge { return decision } if mode == "all" || mode == "bypass" { + if decisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } return rejectApprovalDecision(params: params) } if modeRequestsPersistentApproval(mode), @@ -264,6 +267,9 @@ enum CodexTeamsApprovalBridge { return decision } if modeRequestsPersistentApproval(mode) { + if decisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } return rejectApprovalDecision(params: params) } guard mode == "once" else { @@ -317,6 +323,9 @@ enum CodexTeamsApprovalBridge { return "acceptForSession" } if modeRequestsPersistentApproval(mode) { + if decisionAvailableOrUnspecified("accept", params: params) { + return "accept" + } return rejectApprovalDecision(params: params) } guard mode == "once" else { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index d83bd8c9dee7..2c6c43d91a55 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -301,7 +301,7 @@ struct FeedCoordinatorTests { method: "item/commandExecution/requestApproval", params: ["availableDecisions": ["accept", "decline"]], mode: "always" - )?["decision"] as? String == "decline" + )?["decision"] as? String == "accept" ) #expect( CodexTeamsApprovalBridge.appServerApprovalResponse( @@ -322,7 +322,7 @@ struct FeedCoordinatorTests { method: "item/fileChange/requestApproval", params: ["availableDecisions": ["accept", "decline"]], mode: "always" - )?["decision"] as? String == "decline" + )?["decision"] as? String == "accept" ) #expect( CodexTeamsApprovalBridge.appServerApprovalResponse( From bc0fcbbf287447cb541880e776a54f4600cabad2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:46:35 -0700 Subject: [PATCH 20/29] Isolate Codex watcher approval waits --- CLI/cmux.swift | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 91ab83d93455..629c9828cad8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17964,6 +17964,17 @@ struct CMUXCLI { private static let codexTeamsProbeClientName = "codex_app_server_daemon" private static let codexTeamsWatcherClientName = "cmux-codex-teams" private static let codexTeamsClientVersion = "0.1.0" + private static let codexTeamsWatcherResumeOptOutNotificationMethods = [ + "thread/turn/delta", + "turn/delta", + "item/textDelta", + "item/thinkingDelta", + "item/reasoningDelta", + "item/commandExecution/outputDelta", + "item/commandExecution/stdoutDelta", + "item/commandExecution/stderrDelta", + "item/outputDelta" + ] private struct CodexTeamsSpawn { let parentThreadId: String @@ -18178,7 +18189,8 @@ struct CMUXCLI { method: "thread/resume", params: [ "threadId": threadId, - "excludeTurns": true + "excludeTurns": true, + "optOutNotificationMethods": CMUXCLI.codexTeamsWatcherResumeOptOutNotificationMethods ], notificationHandler: nil, responseTimeout: 2 @@ -18333,7 +18345,8 @@ struct CMUXCLI { method: "thread/resume", params: [ "threadId": threadId, - "excludeTurns": true + "excludeTurns": true, + "optOutNotificationMethods": CMUXCLI.codexTeamsWatcherResumeOptOutNotificationMethods ], notificationHandler: { [weak self] message in try self?.handleAppServerMessage( @@ -18447,7 +18460,8 @@ struct CMUXCLI { } private func pushCodexApprovalToFeed(event: [String: Any]) throws -> [String: Any] { - try socketClient.sendV2(method: "feed.push", params: [ + let feedClient = SocketClient(path: socketClient.socketPath) + return try feedClient.sendV2(method: "feed.push", params: [ "event": event, "wait_timeout_seconds": 120 ], responseTimeout: 125) From cd499280d4728c0881c23eefdb75daf76592a9ba Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:59:44 -0700 Subject: [PATCH 21/29] Gate Codex approval actions by capability --- CLI/CodexTeamsApprovalBridge.swift | 18 +++-- CLI/cmux.swift | 67 ++++++++++++------- Sources/Feed/FeedPanelView.swift | 15 +++-- Sources/Feed/FeedPermissionActionPolicy.swift | 13 +++- cmuxTests/FeedCoordinatorTests.swift | 10 ++- 5 files changed, 87 insertions(+), 36 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index e1bf87a1d95a..11732e189886 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -140,7 +140,7 @@ enum CodexTeamsApprovalBridge { if let changes = item["changes"] as? [[String: Any]] { snapshot["changes"] = changes.prefix(20).map { change in var changeSnapshot: [String: Any] = [:] - for key in ["path", "kind", "type", "status"] { + for key in ["path", "kind", "type", "status", "diff", "summary"] { if let value = change[key], let bounded = boundedApprovalItemValue(value) { changeSnapshot[key] = bounded @@ -208,6 +208,11 @@ enum CodexTeamsApprovalBridge { source != "hermes-agent" } + static func feedSourceSupportsOncePermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + guard source == "codex" else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsOnce + } + static func feedSourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { guard feedSourceSupportsPersistentPermissionModes(source) else { return false } guard source == "codex" else { return true } @@ -404,24 +409,26 @@ enum CodexTeamsApprovalBridge { let data = toolInputJSON.data(using: .utf8), let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + return CodexPermissionCapabilities(supportsOnce: true, supportsAlways: true, supportsAll: true) } let method = object["app_server_method"] as? String let decisions = codexAvailableDecisions(in: object) + let acceptsOnce = decisions?.contains("accept") ?? true let acceptsSession = decisions?.contains("acceptForSession") ?? true switch method { case "item/permissions/requestApproval": - return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + return CodexPermissionCapabilities(supportsOnce: true, supportsAlways: true, supportsAll: true) case "item/commandExecution/requestApproval": return CodexPermissionCapabilities( + supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) ) case "item/fileChange/requestApproval": - return CodexPermissionCapabilities(supportsAlways: acceptsSession, supportsAll: false) + return CodexPermissionCapabilities(supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false) default: - return CodexPermissionCapabilities(supportsAlways: acceptsSession, supportsAll: false) + return CodexPermissionCapabilities(supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false) } } @@ -452,6 +459,7 @@ enum CodexTeamsApprovalBridge { } private struct CodexPermissionCapabilities { + let supportsOnce: Bool let supportsAlways: Bool let supportsAll: Bool } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 629c9828cad8..b14873217611 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18033,7 +18033,18 @@ struct CMUXCLI { session.invalidateAndCancel() } - func initialize(clientName: String, version: String, responseTimeout: TimeInterval = 10) throws { + func initialize( + clientName: String, + version: String, + optOutNotificationMethods: [String] = [], + responseTimeout: TimeInterval = 10 + ) throws { + var capabilities: [String: Any] = [ + "experimentalApi": true + ] + if !optOutNotificationMethods.isEmpty { + capabilities["optOutNotificationMethods"] = optOutNotificationMethods + } _ = try request( method: "initialize", params: [ @@ -18042,9 +18053,7 @@ struct CMUXCLI { "title": "cmux Codex Teams", "version": version ], - "capabilities": [ - "experimentalApi": true - ] + "capabilities": capabilities ], notificationHandler: nil, responseTimeout: responseTimeout @@ -18189,8 +18198,7 @@ struct CMUXCLI { method: "thread/resume", params: [ "threadId": threadId, - "excludeTurns": true, - "optOutNotificationMethods": CMUXCLI.codexTeamsWatcherResumeOptOutNotificationMethods + "excludeTurns": true ], notificationHandler: nil, responseTimeout: 2 @@ -18257,7 +18265,8 @@ struct CMUXCLI { defer { connection.close() } try connection.initialize( clientName: CMUXCLI.codexTeamsWatcherClientName, - version: CMUXCLI.codexTeamsClientVersion + version: CMUXCLI.codexTeamsClientVersion, + optOutNotificationMethods: CMUXCLI.codexTeamsWatcherResumeOptOutNotificationMethods ) resetConnectionSubscriptions() try backfillLoadedThreads(connection: connection) @@ -18345,8 +18354,7 @@ struct CMUXCLI { method: "thread/resume", params: [ "threadId": threadId, - "excludeTurns": true, - "optOutNotificationMethods": CMUXCLI.codexTeamsWatcherResumeOptOutNotificationMethods + "excludeTurns": true ], notificationHandler: { [weak self] message in try self?.handleAppServerMessage( @@ -30324,6 +30332,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { guard item.canResolve else { return "Resolved or informational item" } switch item.kind { case "permissionRequest": + let supportsOnce = feedTUISourceSupportsOncePermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ) let supportsAlways = feedTUISourceSupportsAlwaysPermissionMode( item.source, toolInputJSON: item.toolInputJSON @@ -30332,19 +30344,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { item.source, toolInputJSON: item.toolInputJSON ) - if !supportsAlways && !supportsAll { - return "Permission: Enter/o once, d deny" - } - if !feedTUISourceSupportsBypassPermissions(item.source) { - if supportsAlways && supportsAll { - return "Permission: Enter/o once, a always, l all tools, d deny" - } - if supportsAlways { - return "Permission: Enter/o once, a always, d deny" - } - return "Permission: Enter/o once, l all tools, d deny" - } - return "Permission: Enter/o once, a always, l all tools, b bypass, d deny" + var actions: [String] = [] + if supportsOnce { actions.append("Enter/o once") } + if supportsAlways { actions.append("a always") } + if supportsAll { actions.append("l all tools") } + if feedTUISourceSupportsBypassPermissions(item.source) { actions.append("b bypass") } + actions.append("d deny") + return "Permission: \(actions.joined(separator: ", "))" case "exitPlan": if !feedTUISourceSupportsBypassPermissions(item.source) { return "Plan: Enter default, a auto, m manual, u ultraplan, f replan, d deny" @@ -30369,6 +30375,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } + private func feedTUISourceSupportsOncePermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CMUXCLI.feedSourceSupportsOncePermissionMode(source, toolInputJSON: toolInputJSON) + } + private func feedTUISourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { CMUXCLI.feedSourceSupportsAlwaysPermissionMode(source, toolInputJSON: toolInputJSON) } @@ -30385,6 +30395,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes(source) } + static func feedSourceSupportsOncePermissionMode(_ source: String, toolInputJSON: String?) -> Bool { + CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode(source, toolInputJSON: toolInputJSON) + } + static func feedSourceSupportsAlwaysPermissionMode(_ source: String, toolInputJSON: String?) -> Bool { CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode(source, toolInputJSON: toolInputJSON) } @@ -30412,7 +30426,14 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case "permissionRequest": let mode: String switch key { - case .enter, .once: + case .enter where feedTUISourceSupportsOncePermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ), + .once where feedTUISourceSupportsOncePermissionMode( + item.source, + toolInputJSON: item.toolInputJSON + ): mode = "once" case .always where feedTUISourceSupportsAlwaysPermissionMode( item.source, diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index ff1821e6b87c..950a80932b52 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -1447,12 +1447,17 @@ private struct PermissionActionArea: View { onApprove(.deny) } .accessibilityIdentifier("FeedPermissionDenyButton") - FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"), - kind: .light, size: .medium, fullWidth: true) { - onActionRow() - onApprove(.once) + if FeedPermissionActionPolicy.supportsOncePermissionMode( + source: source, + toolInputJSON: toolInputJSON + ) { + FeedButton(label: String(localized: "feed.permission.once", defaultValue: "Allow Once"), + kind: .light, size: .medium, fullWidth: true) { + onActionRow() + onApprove(.once) + } + .accessibilityIdentifier("FeedPermissionAllowOnceButton") } - .accessibilityIdentifier("FeedPermissionAllowOnceButton") if FeedPermissionActionPolicy.supportsAlwaysPermissionMode( source: source, toolInputJSON: toolInputJSON diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 8cd9a3d97569..94971f1c7915 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -6,6 +6,11 @@ enum FeedPermissionActionPolicy { source != .hermesAgent } + static func supportsOncePermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { + guard source == .codex else { return true } + return codexCapabilities(toolInputJSON: toolInputJSON).supportsOnce + } + static func supportsAlwaysPermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { guard supportsPersistentPermissionModes(source: source) else { return false } guard source == .codex else { return true } @@ -27,30 +32,35 @@ enum FeedPermissionActionPolicy { let data = toolInputJSON.data(using: .utf8), let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - return CodexPermissionCapabilities(supportsAlways: true, supportsAll: true) + return CodexPermissionCapabilities(supportsOnce: true, supportsAlways: true, supportsAll: true) } let method = object["app_server_method"] as? String let decisions = codexAvailableDecisions(in: object) + let acceptsOnce = decisions?.contains("accept") ?? true let acceptsSession = decisions?.contains("acceptForSession") ?? true switch method { case "item/permissions/requestApproval": return CodexPermissionCapabilities( + supportsOnce: true, supportsAlways: true, supportsAll: true ) case "item/commandExecution/requestApproval": return CodexPermissionCapabilities( + supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) ) case "item/fileChange/requestApproval": return CodexPermissionCapabilities( + supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false ) default: return CodexPermissionCapabilities( + supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false ) @@ -94,6 +104,7 @@ enum FeedPermissionActionPolicy { } private struct CodexPermissionCapabilities { + let supportsOnce: Bool let supportsAlways: Bool let supportsAll: Bool } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 2c6c43d91a55..b8730f31d7dd 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -73,8 +73,10 @@ struct FeedCoordinatorTests { let codexOneShotOnly = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept","decline"]} """# + #expect(FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode("codex", toolInputJSON: codexOneShotOnly)) #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexOneShotOnly)) #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexOneShotOnly)) @@ -87,8 +89,10 @@ struct FeedCoordinatorTests { let codexAmendment = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":[{"acceptWithExecpolicyAmendment":{}}],"proposed_execpolicy_amendment":[{"kind":"prefix","value":"npm test"}]} """# + #expect(!FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: codexAmendment)) #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexAmendment)) #expect(FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexAmendment)) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode("codex", toolInputJSON: codexAmendment)) #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexAmendment)) #expect(CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexAmendment)) @@ -344,7 +348,8 @@ struct FeedCoordinatorTests { "changes": [ [ "path": "/tmp/file.txt", - "diff": String(repeating: "z", count: 100_000) + "diff": String(repeating: "z", count: 100_000), + "summary": "file summary" ] ] ]) @@ -355,7 +360,8 @@ struct FeedCoordinatorTests { #expect(snapshot["output"] == nil) let changes = try #require(snapshot["changes"] as? [[String: Any]]) #expect(changes.first?["path"] as? String == "/tmp/file.txt") - #expect(changes.first?["diff"] == nil) + #expect((changes.first?["diff"] as? String)?.count == 4_096) + #expect(changes.first?["summary"] as? String == "file summary") } @Test func blockingIngestExpiresItemWhenHookTimesOut() async { From 1a6aa40fb8839061299510d730555b7f3b303c86 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:07:16 -0700 Subject: [PATCH 22/29] Treat file-change approvals as one-shot by default --- CLI/CodexTeamsApprovalBridge.swift | 8 ++++++-- Sources/Feed/FeedPermissionActionPolicy.swift | 2 +- cmuxTests/FeedCoordinatorTests.swift | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 11732e189886..547e8291b07e 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -324,7 +324,7 @@ enum CodexTeamsApprovalBridge { private static func fileChangeApprovalDecision(params: [String: Any], mode: String) -> String { if mode == "deny" { return rejectApprovalDecision(params: params) } if modeRequestsPersistentApproval(mode) - && decisionAvailableOrUnspecified("acceptForSession", params: params) { + && availableDecisions(params).contains("acceptForSession") { return "acceptForSession" } if modeRequestsPersistentApproval(mode) { @@ -426,7 +426,11 @@ enum CodexTeamsApprovalBridge { supportsAll: codexSupportsAmendmentDecision(object: object, decisions: decisions) ) case "item/fileChange/requestApproval": - return CodexPermissionCapabilities(supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false) + return CodexPermissionCapabilities( + supportsOnce: acceptsOnce, + supportsAlways: decisions?.contains("acceptForSession") ?? false, + supportsAll: false + ) default: return CodexPermissionCapabilities(supportsOnce: acceptsOnce, supportsAlways: acceptsSession, supportsAll: false) } diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 94971f1c7915..5775fd7d0c67 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -55,7 +55,7 @@ enum FeedPermissionActionPolicy { case "item/fileChange/requestApproval": return CodexPermissionCapabilities( supportsOnce: acceptsOnce, - supportsAlways: acceptsSession, + supportsAlways: decisions?.contains("acceptForSession") ?? false, supportsAll: false ) default: diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index b8730f31d7dd..12833f0b8fd3 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -319,6 +319,13 @@ struct FeedCoordinatorTests { method: "item/fileChange/requestApproval", params: [:], mode: "always" + )?["decision"] as? String == "accept" + ) + #expect( + CodexTeamsApprovalBridge.appServerApprovalResponse( + method: "item/fileChange/requestApproval", + params: ["availableDecisions": ["acceptForSession", "decline"]], + mode: "always" )?["decision"] as? String == "acceptForSession" ) #expect( From cf30c9ec5722c90a5adee2d548f298a668df8d9b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:16:40 -0700 Subject: [PATCH 23/29] Validate Codex notification approval actions --- CLI/cmux.swift | 1 + Sources/AppDelegate.swift | 52 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b14873217611..43e67e154c70 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18469,6 +18469,7 @@ struct CMUXCLI { private func pushCodexApprovalToFeed(event: [String: Any]) throws -> [String: Any] { let feedClient = SocketClient(path: socketClient.socketPath) + try feedClient.connect() return try feedClient.sendV2(method: "feed.push", params: [ "event": event, "wait_timeout_seconds": 120 diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a1dc079c3d4c..1109687fdb1c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15358,9 +15358,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch response.actionIdentifier { case "feed.permission.once": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.once)) + guard let decision = feedPermissionNotificationDecision(requestId: requestId, requestedMode: .once) else { + return true + } + FeedCoordinator.shared.deliverReply(requestId: requestId, decision: decision) case "feed.permission.always": - FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.always)) + guard let decision = feedPermissionNotificationDecision(requestId: requestId, requestedMode: .always) else { + return true + } + FeedCoordinator.shared.deliverReply(requestId: requestId, decision: decision) case "feed.permission.deny": FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.deny)) case "feed.exit_plan.ultraplan": @@ -15384,6 +15390,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func feedPermissionNotificationDecision( + requestId: String, + requestedMode: WorkstreamPermissionMode + ) -> WorkstreamDecision? { + guard let item = FeedCoordinator.shared.snapshot(pendingOnly: false).reversed().first(where: { item in + guard case .permissionRequest(let itemRequestId, _, _, _) = item.payload else { return false } + return itemRequestId == requestId + }) else { + return .permission(requestedMode) + } + guard case .permissionRequest(_, _, let toolInputJSON, _) = item.payload else { + return .permission(requestedMode) + } + + switch requestedMode { + case .once: + guard FeedPermissionActionPolicy.supportsOncePermissionMode( + source: item.source, + toolInputJSON: toolInputJSON + ) else { + return nil + } + return .permission(.once) + case .always: + if FeedPermissionActionPolicy.supportsAlwaysPermissionMode( + source: item.source, + toolInputJSON: toolInputJSON + ) { + return .permission(.always) + } + if FeedPermissionActionPolicy.supportsOncePermissionMode( + source: item.source, + toolInputJSON: toolInputJSON + ) { + return .permission(.once) + } + return nil + default: + return .permission(requestedMode) + } + } + private func disableNativeTabbingShortcut() { guard let menu = NSApp.mainMenu else { return } disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) From 73aa15c8c8b022f7e9bc952f62363241836c9f44 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:25:05 -0700 Subject: [PATCH 24/29] Close Codex approval feed sockets --- CLI/cmux.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 43e67e154c70..da89cf4ede7d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18470,6 +18470,7 @@ struct CMUXCLI { private func pushCodexApprovalToFeed(event: [String: Any]) throws -> [String: Any] { let feedClient = SocketClient(path: socketClient.socketPath) try feedClient.connect() + defer { feedClient.close() } return try feedClient.sendV2(method: "feed.push", params: [ "event": event, "wait_timeout_seconds": 120 From 7aab7f561c425d15ec0c2304b5a35f768ffda152 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:34:02 -0700 Subject: [PATCH 25/29] Harden Codex approval feed bridge --- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- CLI/CodexTeamsApprovalBridge.swift | 116 ++++++++++++++---- CLI/cmux.swift | 40 +++++- Resources/Localizable.xcstrings | 17 +++ Sources/AppDelegate.swift | 17 +++ Sources/Feed/FeedPermissionActionPolicy.swift | 2 +- cmuxTests/FeedCoordinatorTests.swift | 21 +++- tests/test_codex_feed_hooks.py | 2 +- 8 files changed, 180 insertions(+), 37 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index e94565309c16..0ed445c5bc2d 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -158,7 +158,7 @@ extension CMUXCLI { name: "codex", displayName: "Codex", statusKey: "codex", configDir: ".codex", configFile: "hooks.json", configDirEnvOverride: "CODEX_HOME", sessionStoreSuffix: "codex", disableEnvVar: "CMUX_CODEX_HOOKS_DISABLED", - hookMarker: "cmux hooks codex", format: .nested(timeoutMs: 5000), + hookMarker: "cmux hooks codex", format: .nested(timeoutMs: 5), events: [ .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), .init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit"), diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 547e8291b07e..560b9426f81d 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -34,7 +34,7 @@ enum CodexTeamsApprovalBridge { "app_server_method": method, "request_id": requestIdString(requestId), "item_id": itemId, - "approval_params": params + "approval_params": approvalParamsSnapshot(params) ] if let turnId { toolInput["turn_id"] = turnId } if let reason { toolInput["reason"] = reason } @@ -43,30 +43,16 @@ enum CodexTeamsApprovalBridge { if let approvalId = stringValue(in: params, keys: ["approvalId", "approval_id"]) { toolInput["approval_id"] = approvalId } - if let grantRoot = params["grantRoot"] ?? params["grant_root"] { - toolInput["grant_root"] = grantRoot - } + setBoundedToolInput(&toolInput, key: "grant_root", value: params["grantRoot"] ?? params["grant_root"]) if let available = params["availableDecisions"] ?? params["available_decisions"] { toolInput["available_decisions"] = decisionNames(available) } - if let permissions = params["permissions"] { - toolInput["permissions"] = permissions - } - if let networkApprovalContext = params["networkApprovalContext"] ?? params["network_approval_context"] { - toolInput["network_approval_context"] = networkApprovalContext - } - if let additionalPermissions = params["additionalPermissions"] ?? params["additional_permissions"] { - toolInput["additional_permissions"] = additionalPermissions - } - if let commandActions = params["commandActions"] ?? params["command_actions"] { - toolInput["command_actions"] = commandActions - } - if let proposed = params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"] { - toolInput["proposed_execpolicy_amendment"] = proposed - } - if let proposed = params["proposedNetworkPolicyAmendments"] ?? params["proposed_network_policy_amendments"] { - toolInput["proposed_network_policy_amendments"] = proposed - } + setBoundedToolInput(&toolInput, key: "permissions", value: params["permissions"]) + setBoundedToolInput(&toolInput, key: "network_approval_context", value: params["networkApprovalContext"] ?? params["network_approval_context"]) + setBoundedToolInput(&toolInput, key: "additional_permissions", value: params["additionalPermissions"] ?? params["additional_permissions"]) + setBoundedToolInput(&toolInput, key: "command_actions", value: params["commandActions"] ?? params["command_actions"]) + setBoundedToolInput(&toolInput, key: "proposed_execpolicy_amendment", value: params["proposedExecpolicyAmendment"] ?? params["proposed_execpolicy_amendment"]) + setBoundedToolInput(&toolInput, key: "proposed_network_policy_amendments", value: params["proposedNetworkPolicyAmendments"] ?? params["proposed_network_policy_amendments"]) if let relatedItem { toolInput["related_item"] = relatedItem if command == nil, @@ -321,10 +307,92 @@ enum CodexTeamsApprovalBridge { return nil } + private static func approvalParamsSnapshot(_ params: [String: Any]) -> [String: Any] { + let keys = [ + "threadId", + "thread_id", + "turnId", + "turn_id", + "itemId", + "item_id", + "approvalId", + "approval_id", + "environmentId", + "environment_id", + "cwd", + "reason", + "command", + "grantRoot", + "grant_root", + "availableDecisions", + "available_decisions", + "permissions", + "networkApprovalContext", + "network_approval_context", + "additionalPermissions", + "additional_permissions", + "commandActions", + "command_actions", + "proposedExecpolicyAmendment", + "proposed_execpolicy_amendment", + "proposedNetworkPolicyAmendments", + "proposed_network_policy_amendments" + ] + var snapshot: [String: Any] = [:] + for key in keys { + guard let value = params[key], + let bounded = boundedApprovalParamValue(value, depth: 0) else { + continue + } + snapshot[key] = bounded + } + return snapshot + } + + private static func setBoundedToolInput(_ toolInput: inout [String: Any], key: String, value: Any?) { + guard let value, + let bounded = boundedApprovalParamValue(value, depth: 0) else { + return + } + toolInput[key] = bounded + } + + private static func boundedApprovalParamValue(_ value: Any, depth: Int) -> Any? { + let stringLimit = 4_096 + let collectionLimit = 50 + guard depth <= 5 else { return nil } + + if let string = value as? String { + guard string.count > stringLimit else { return string } + let index = string.index(string.startIndex, offsetBy: stringLimit) + return String(string[.. String { if mode == "deny" { return rejectApprovalDecision(params: params) } if modeRequestsPersistentApproval(mode) - && availableDecisions(params).contains("acceptForSession") { + && decisionAvailableOrUnspecified("acceptForSession", params: params) { return "acceptForSession" } if modeRequestsPersistentApproval(mode) { @@ -428,7 +496,7 @@ enum CodexTeamsApprovalBridge { case "item/fileChange/requestApproval": return CodexPermissionCapabilities( supportsOnce: acceptsOnce, - supportsAlways: decisions?.contains("acceptForSession") ?? false, + supportsAlways: acceptsSession, supportsAll: false ) default: diff --git a/CLI/cmux.swift b/CLI/cmux.swift index da89cf4ede7d..7794beff420a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4657,7 +4657,7 @@ struct CMUXCLI { ) case "__codex-teams-watch": - try runCodexTeamsWatcher(commandArgs: commandArgs, client: client) + try runCodexTeamsWatcher(commandArgs: commandArgs, client: client, socketPassword: socketPasswordArg) case "capture-pane", "resize-pane", @@ -5445,6 +5445,20 @@ struct CMUXCLI { explicitPassword: String?, socketPath: String, responseTimeout: TimeInterval? = nil + ) throws { + try Self.authenticateSocketClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath, + responseTimeout: responseTimeout + ) + } + + private static func authenticateSocketClientIfNeeded( + _ client: SocketClient, + explicitPassword: String?, + socketPath: String, + responseTimeout: TimeInterval? = nil ) throws { if let socketPassword = SocketPasswordResolver.resolve( explicit: explicitPassword, @@ -18218,6 +18232,7 @@ struct CMUXCLI { private let launchPath: String? private let maxAutoDepth: Int private let socketClient: SocketClient + private let socketPassword: String? private var knownThreadIds = Set() private var parentByThreadId: [String: String] = [:] @@ -18242,7 +18257,8 @@ struct CMUXCLI { codexExecutable: String, launchPath: String?, maxAutoDepth: Int, - socketClient: SocketClient + socketClient: SocketClient, + socketPassword: String? ) { self.appServerURL = appServerURL self.workspaceId = workspaceId @@ -18251,6 +18267,7 @@ struct CMUXCLI { self.launchPath = launchPath self.maxAutoDepth = max(0, maxAutoDepth) self.socketClient = socketClient + self.socketPassword = socketPassword } func run() throws { @@ -18471,6 +18488,11 @@ struct CMUXCLI { let feedClient = SocketClient(path: socketClient.socketPath) try feedClient.connect() defer { feedClient.close() } + try CMUXCLI.authenticateSocketClientIfNeeded( + feedClient, + explicitPassword: socketPassword, + socketPath: socketClient.socketPath + ) return try feedClient.sendV2(method: "feed.push", params: [ "event": event, "wait_timeout_seconds": 120 @@ -19323,7 +19345,7 @@ struct CMUXCLI { process.terminate() } - private func runCodexTeamsWatcher(commandArgs: [String], client: SocketClient) throws { + private func runCodexTeamsWatcher(commandArgs: [String], client: SocketClient, socketPassword: String?) throws { let (workspaceId, rem0) = parseOption(commandArgs, name: "--workspace-id") let (surfaceId, rem1) = parseOption(rem0, name: "--surface-id") let (appServerURL, rem2) = parseOption(rem1, name: "--app-server-url") @@ -19377,7 +19399,8 @@ struct CMUXCLI { codexExecutable: (codexExecutable?.isEmpty == false ? codexExecutable! : "codex"), launchPath: launchPath, maxAutoDepth: maxDepth, - socketClient: client + socketClient: client, + socketPassword: socketPassword ) withExtendedLifetime(ownerSource) { do { @@ -25543,7 +25566,7 @@ struct CMUXCLI { result[agentEvent] = entries case .nested: var groups = result[agentEvent] as? [[String: Any]] ?? [] - let timeout = nestedHookTimeout(feedTimeoutMs, for: def) + let timeout = nestedFeedHookTimeout(feedTimeoutMs, for: def) groups.append([ "hooks": [["type": "command", "command": feedCmd, "timeout": timeout] as [String: Any]] ] as [String: Any]) @@ -25564,7 +25587,12 @@ struct CMUXCLI { } private func nestedHookTimeout(_ timeoutMs: Int, for def: AgentHookDef) -> Int { - guard def.name == "codex" || def.name == "grok" else { return timeoutMs } + guard def.name == "grok" else { return max(timeoutMs, 1) } + return Self.timeoutSecondsFromMilliseconds(timeoutMs) + } + + private func nestedFeedHookTimeout(_ timeoutMs: Int, for def: AgentHookDef) -> Int { + guard def.name == "codex" || def.name == "grok" else { return max(timeoutMs, 1) } return Self.timeoutSecondsFromMilliseconds(timeoutMs) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b110ef05c49a..cb80bc8799e3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -152713,6 +152713,23 @@ } } }, + "feed.notification.permission.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのツール" + } + } + } + }, "feed.notification.permission.deny": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1109687fdb1c..fd3464307885 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15294,6 +15294,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent identifier: "feed.permission.always", title: String(localized: "feed.notification.permission.always", defaultValue: "Always") ), + UNNotificationAction( + identifier: "feed.permission.all", + title: String(localized: "feed.notification.permission.all", defaultValue: "All tools") + ), UNNotificationAction( identifier: "feed.permission.deny", title: String(localized: "feed.notification.permission.deny", defaultValue: "Deny"), @@ -15367,6 +15371,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } FeedCoordinator.shared.deliverReply(requestId: requestId, decision: decision) + case "feed.permission.all": + guard let decision = feedPermissionNotificationDecision(requestId: requestId, requestedMode: .all) else { + return true + } + FeedCoordinator.shared.deliverReply(requestId: requestId, decision: decision) case "feed.permission.deny": FeedCoordinator.shared.deliverReply(requestId: requestId, decision: .permission(.deny)) case "feed.exit_plan.ultraplan": @@ -15427,6 +15436,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return .permission(.once) } return nil + case .all: + guard FeedPermissionActionPolicy.supportsAllPermissionMode( + source: item.source, + toolInputJSON: toolInputJSON + ) else { + return nil + } + return .permission(.all) default: return .permission(requestedMode) } diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 5775fd7d0c67..94971f1c7915 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -55,7 +55,7 @@ enum FeedPermissionActionPolicy { case "item/fileChange/requestApproval": return CodexPermissionCapabilities( supportsOnce: acceptsOnce, - supportsAlways: decisions?.contains("acceptForSession") ?? false, + supportsAlways: acceptsSession, supportsAll: false ) default: diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 12833f0b8fd3..338551e6afd1 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -86,6 +86,12 @@ struct FeedCoordinatorTests { #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexSession)) #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexSession)) + let codexFileChangeFallback = #""" + {"app_server_method":"item/fileChange/requestApproval"} + """# + #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexFileChangeFallback)) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexFileChangeFallback)) + let codexAmendment = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":[{"acceptWithExecpolicyAmendment":{}}],"proposed_execpolicy_amendment":[{"kind":"prefix","value":"npm test"}]} """# @@ -119,11 +125,16 @@ struct FeedCoordinatorTests { "command": "touch /tmp/cmux-security-review", "cwd": "/tmp/project", "reason": "requires approval", + "unboundedRawPatch": String(repeating: "x", count: 8_000), "additionalPermissions": [ "fileSystem": ["write": ["/tmp/project"]] ], "networkApprovalContext": ["host": "example.com"], - "commandActions": [["type": "write", "path": "/tmp/cmux-security-review"]], + "commandActions": [[ + "type": "write", + "path": "/tmp/cmux-security-review", + "diff": String(repeating: "d", count: 8_000) + ]], "proposedExecpolicyAmendment": [["kind": "prefix", "value": "touch"]], "availableDecisions": ["accept", "acceptForSession", "decline"] ], @@ -150,10 +161,12 @@ struct FeedCoordinatorTests { #expect(toolInput["item_id"] as? String == "approval-1") #expect(toolInput["turn_id"] as? String == "turn-1") #expect(toolInput["command"] as? String == "touch /tmp/cmux-security-review") - #expect(toolInput["approval_params"] != nil) + let approvalParams = try #require(toolInput["approval_params"] as? [String: Any]) + #expect(approvalParams["unboundedRawPatch"] == nil) #expect(toolInput["additional_permissions"] != nil) #expect(toolInput["network_approval_context"] != nil) - #expect(toolInput["command_actions"] != nil) + let commandActions = try #require(toolInput["command_actions"] as? [[String: Any]]) + #expect((commandActions.first?["diff"] as? String)?.count == 4_096) #expect(toolInput["proposed_execpolicy_amendment"] != nil) #expect((toolInput["related_item"] as? [String: Any])?["type"] as? String == "commandExecution") @@ -319,7 +332,7 @@ struct FeedCoordinatorTests { method: "item/fileChange/requestApproval", params: [:], mode: "always" - )?["decision"] as? String == "accept" + )?["decision"] as? String == "acceptForSession" ) #expect( CodexTeamsApprovalBridge.appServerApprovalResponse( diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 9d0b3361509c..ef63ebfdcc89 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -246,7 +246,7 @@ def test_codex_stop_reaps_transcript_monitor(cli_path: str, root: Path) -> None: f"hooks codex stop failed exit={result.returncode}\n" f"stdout={result.stdout}\nstderr={result.stderr}" ) - wait_for_monitor_pids(session_id, present=False, timeout=5) + wait_for_monitor_pids(session_id, present=False, timeout=30) finally: for pid in monitor_pids_for_session(session_id): subprocess.run(["/bin/kill", str(pid)], check=False) From 8863a423c073a63a98c00bc1f97957b51c3e96f9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:32:28 -0700 Subject: [PATCH 26/29] Fix feed capability test encoder call --- cmuxTests/FeedCoordinatorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 90f75432275f..bbfb0fb99aba 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -143,7 +143,7 @@ struct FeedCoordinatorTests { ) ) - let dict = FeedCoordinator.itemDict(item) + let dict = FeedSocketEncoding.itemDict(item) let displayToolInput = try #require(dict["tool_input"] as? String) let capabilityToolInput = try #require(dict["tool_input_capabilities"] as? String) From 524cbea593570dd0b3e380a7850539c270e797f7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:43:01 -0700 Subject: [PATCH 27/29] Keep Codex watcher approvals soft fallback --- CLI/cmux.swift | 49 ++++--------------------------------------------- 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8d65b213d825..614330879000 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18350,17 +18350,7 @@ struct CMUXCLI { try handleApprovalRequest(message, method: method, requestId: requestId, connection: connection) { return } - if let requestId = message["id"] { - // This watcher is not the owning interactive client, but - // app-server JSON-RPC requests still require a response on - // this connection so the turn is never left pending behind us. - try connection.respondError( - requestId: requestId, - code: -32601, - message: "cmux Codex Teams watcher does not handle \(method)" - ) - return - } + if message["id"] != nil { return } guard method.hasPrefix("thread/"), let params = message["params"] as? [String: Any], let threadObject = params["thread"] as? [String: Any], @@ -18470,11 +18460,7 @@ struct CMUXCLI { ) throws -> Bool { guard CMUXCLI.codexTeamsApprovalMethods.contains(method) else { return false } guard let params = message["params"] as? [String: Any] else { - try connection.respondError( - requestId: requestId, - code: -32602, - message: "\(method) is missing params" - ) + fputs("cmux codex-teams watcher ignoring malformed approval \(method) request \(CMUXCLI.requestIdString(requestId))\n", stderr) return true } let relatedItem = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) @@ -18482,11 +18468,6 @@ struct CMUXCLI { let suppressionKey = approvalSuppressionKey(method: method, requestId: requestId, params: params) if approvalIsSuppressed(suppressionKey) { fputs("cmux codex-teams watcher ignoring previously unresolved approval \(suppressionKey)\n", stderr) - try respondWithApprovalError( - requestId: requestId, - connection: connection, - reason: "Feed approval was previously unresolved" - ) return true } let feedEvent = CMUXCLI.codexTeamsFeedEvent( @@ -18502,22 +18483,12 @@ struct CMUXCLI { response = try pushCodexApprovalToFeed(event: feedEvent) } catch { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher returning no-decision error for approval \(suppressionKey) after Feed push failed: \(error)\n", stderr) - try respondWithApprovalError( - requestId: requestId, - connection: connection, - reason: "Feed push failed" - ) + fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to Codex after Feed push failed: \(error)\n", stderr) return true } guard let decision = CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: response) else { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher returning no-decision error for approval \(suppressionKey) because Feed did not resolve it\n", stderr) - try respondWithApprovalError( - requestId: requestId, - connection: connection, - reason: "Feed did not resolve approval" - ) + fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to Codex because Feed did not resolve it\n", stderr) return true } guard let result = CMUXCLI.codexTeamsAppServerApprovalResponse( @@ -18531,18 +18502,6 @@ struct CMUXCLI { return true } - private func respondWithApprovalError( - requestId: Any, - connection: CodexTeamsAppServerConnection, - reason: String - ) throws { - try connection.respondError( - requestId: requestId, - code: -32800, - message: reason - ) - } - private func approvalSuppressionKey(method: String, requestId: Any, params: [String: Any]) -> String { let stableId = CMUXCLI.stringValue( in: params, From c36c8bb6645e6430fd911e4d5165a72a2c0023e8 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:55:29 -0700 Subject: [PATCH 28/29] Answer Codex watcher approval fallbacks --- CLI/CodexTeamsApprovalBridge.swift | 25 +++++++++++++++++ CLI/cmux.swift | 41 ++++++++++++++++++++++++++-- cmuxTests/FeedCoordinatorTests.swift | 35 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index ce6e99c0f8b9..748e53526a88 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -113,6 +113,20 @@ enum CodexTeamsApprovalBridge { } } + static func appServerNoDecisionApprovalResponse( + method: String, + params: [String: Any] + ) -> [String: Any]? { + switch method { + case "item/commandExecution/requestApproval", "item/fileChange/requestApproval": + return ["decision": noDecisionApprovalDecision(params: params)] + case "item/permissions/requestApproval": + return permissionsApprovalResponse(params: params, mode: "deny") + default: + return nil + } + } + static func approvalItemSnapshot(_ item: [String: Any]) -> [String: Any] { var snapshot: [String: Any] = [:] for key in ["id", "type", "threadId", "thread_id", "turnId", "turn_id", "command", "cwd", "path", "status"] { @@ -464,6 +478,17 @@ enum CodexTeamsApprovalBridge { return "decline" } + private static func noDecisionApprovalDecision(params: [String: Any]) -> String { + let available = availableDecisions(params) + if available.contains("cancel") || available.isEmpty { + return "cancel" + } + if available.contains("decline") { + return "decline" + } + return "cancel" + } + private static func decisionNames(_ raw: Any) -> [String] { let values = raw as? [Any] ?? [] return values.compactMap { value in diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 614330879000..31768632b22c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18461,13 +18461,15 @@ struct CMUXCLI { guard CMUXCLI.codexTeamsApprovalMethods.contains(method) else { return false } guard let params = message["params"] as? [String: Any] else { fputs("cmux codex-teams watcher ignoring malformed approval \(method) request \(CMUXCLI.requestIdString(requestId))\n", stderr) + try respondWithNoDecisionApproval(method: method, params: [:], requestId: requestId, connection: connection) return true } let relatedItem = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) .flatMap { cachedApprovalItem(itemId: $0) } let suppressionKey = approvalSuppressionKey(method: method, requestId: requestId, params: params) if approvalIsSuppressed(suppressionKey) { - fputs("cmux codex-teams watcher ignoring previously unresolved approval \(suppressionKey)\n", stderr) + fputs("cmux codex-teams watcher sending no-decision fallback for previously unresolved approval \(suppressionKey)\n", stderr) + try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) return true } let feedEvent = CMUXCLI.codexTeamsFeedEvent( @@ -18483,12 +18485,14 @@ struct CMUXCLI { response = try pushCodexApprovalToFeed(event: feedEvent) } catch { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to Codex after Feed push failed: \(error)\n", stderr) + fputs("cmux codex-teams watcher sending no-decision fallback for approval \(suppressionKey) after Feed push failed: \(error)\n", stderr) + try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) return true } guard let decision = CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: response) else { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to Codex because Feed did not resolve it\n", stderr) + fputs("cmux codex-teams watcher sending no-decision fallback for approval \(suppressionKey) because Feed did not resolve it\n", stderr) + try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) return true } guard let result = CMUXCLI.codexTeamsAppServerApprovalResponse( @@ -18496,12 +18500,33 @@ struct CMUXCLI { params: params, mode: decision ) else { + try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) return true } try connection.respond(requestId: requestId, result: result) return true } + private func respondWithNoDecisionApproval( + method: String, + params: [String: Any], + requestId: Any, + connection: CodexTeamsAppServerConnection + ) throws { + guard let result = CMUXCLI.codexTeamsAppServerNoDecisionApprovalResponse( + method: method, + params: params + ) else { + try connection.respondError( + requestId: requestId, + code: -32601, + message: "cmux Codex Teams watcher cannot respond to \(method)" + ) + return + } + try connection.respond(requestId: requestId, result: result) + } + private func approvalSuppressionKey(method: String, requestId: Any, params: [String: Any]) -> String { let stableId = CMUXCLI.stringValue( in: params, @@ -18777,6 +18802,16 @@ struct CMUXCLI { ) } + static func codexTeamsAppServerNoDecisionApprovalResponse( + method: String, + params: [String: Any] + ) -> [String: Any]? { + CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( + method: method, + params: params + ) + } + static func codexTeamsApprovalItemSnapshot(_ item: [String: Any]) -> [String: Any] { CodexTeamsApprovalBridge.approvalItemSnapshot(item) } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index bbfb0fb99aba..50523793fef2 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -400,6 +400,41 @@ struct FeedCoordinatorTests { #expect(CodexTeamsApprovalBridge.permissionMode(fromFeedPushResponse: ["status": "timed_out"]) == nil) } + @Test func codexAppServerNoDecisionApprovalResponseKeepsWatcherNonBlocking() throws { + let commandCancel = try #require( + CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( + method: "item/commandExecution/requestApproval", + params: [:] + ) + ) + #expect(commandCancel["decision"] as? String == "cancel") + + let commandDecline = try #require( + CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( + method: "item/commandExecution/requestApproval", + params: ["availableDecisions": ["accept", "decline"]] + ) + ) + #expect(commandDecline["decision"] as? String == "decline") + + let fileCancel = try #require( + CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( + method: "item/fileChange/requestApproval", + params: ["availableDecisions": ["accept", "acceptForSession", "cancel"]] + ) + ) + #expect(fileCancel["decision"] as? String == "cancel") + + let permissionsDeny = try #require( + CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( + method: "item/permissions/requestApproval", + params: ["permissions": ["network": ["enabled": true]]] + ) + ) + #expect(permissionsDeny["scope"] as? String == "turn") + #expect((permissionsDeny["permissions"] as? [String: Any])?.isEmpty == true) + } + @Test func codexApprovalItemSnapshotStripsLargePayloads() throws { let snapshot = CodexTeamsApprovalBridge.approvalItemSnapshot([ "id": "call-1", From bb19d16ff31f356f30ac409d1a4cf02d1d7ba45d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:07:52 -0700 Subject: [PATCH 29/29] Keep Codex approval fallback native --- CLI/CodexTeamsApprovalBridge.swift | 25 ----------------- CLI/cmux.swift | 42 +++------------------------- cmuxTests/FeedCoordinatorTests.swift | 35 ----------------------- 3 files changed, 4 insertions(+), 98 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index 748e53526a88..ce6e99c0f8b9 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -113,20 +113,6 @@ enum CodexTeamsApprovalBridge { } } - static func appServerNoDecisionApprovalResponse( - method: String, - params: [String: Any] - ) -> [String: Any]? { - switch method { - case "item/commandExecution/requestApproval", "item/fileChange/requestApproval": - return ["decision": noDecisionApprovalDecision(params: params)] - case "item/permissions/requestApproval": - return permissionsApprovalResponse(params: params, mode: "deny") - default: - return nil - } - } - static func approvalItemSnapshot(_ item: [String: Any]) -> [String: Any] { var snapshot: [String: Any] = [:] for key in ["id", "type", "threadId", "thread_id", "turnId", "turn_id", "command", "cwd", "path", "status"] { @@ -478,17 +464,6 @@ enum CodexTeamsApprovalBridge { return "decline" } - private static func noDecisionApprovalDecision(params: [String: Any]) -> String { - let available = availableDecisions(params) - if available.contains("cancel") || available.isEmpty { - return "cancel" - } - if available.contains("decline") { - return "decline" - } - return "cancel" - } - private static func decisionNames(_ raw: Any) -> [String] { let values = raw as? [Any] ?? [] return values.compactMap { value in diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 31768632b22c..07f8f39b13d2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18461,15 +18461,13 @@ struct CMUXCLI { guard CMUXCLI.codexTeamsApprovalMethods.contains(method) else { return false } guard let params = message["params"] as? [String: Any] else { fputs("cmux codex-teams watcher ignoring malformed approval \(method) request \(CMUXCLI.requestIdString(requestId))\n", stderr) - try respondWithNoDecisionApproval(method: method, params: [:], requestId: requestId, connection: connection) return true } let relatedItem = CMUXCLI.stringValue(in: params, keys: ["itemId", "item_id"]) .flatMap { cachedApprovalItem(itemId: $0) } let suppressionKey = approvalSuppressionKey(method: method, requestId: requestId, params: params) if approvalIsSuppressed(suppressionKey) { - fputs("cmux codex-teams watcher sending no-decision fallback for previously unresolved approval \(suppressionKey)\n", stderr) - try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) + fputs("cmux codex-teams watcher leaving previously unresolved approval \(suppressionKey) to native Codex\n", stderr) return true } let feedEvent = CMUXCLI.codexTeamsFeedEvent( @@ -18485,14 +18483,12 @@ struct CMUXCLI { response = try pushCodexApprovalToFeed(event: feedEvent) } catch { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher sending no-decision fallback for approval \(suppressionKey) after Feed push failed: \(error)\n", stderr) - try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) + fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to native Codex after Feed push failed: \(error)\n", stderr) return true } guard let decision = CMUXCLI.codexTeamsPermissionMode(fromFeedPushResponse: response) else { suppressApproval(suppressionKey) - fputs("cmux codex-teams watcher sending no-decision fallback for approval \(suppressionKey) because Feed did not resolve it\n", stderr) - try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) + fputs("cmux codex-teams watcher leaving approval \(suppressionKey) to native Codex because Feed did not resolve it\n", stderr) return true } guard let result = CMUXCLI.codexTeamsAppServerApprovalResponse( @@ -18500,33 +18496,13 @@ struct CMUXCLI { params: params, mode: decision ) else { - try respondWithNoDecisionApproval(method: method, params: params, requestId: requestId, connection: connection) + fputs("cmux codex-teams watcher cannot map Feed decision for \(suppressionKey); leaving it to native Codex\n", stderr) return true } try connection.respond(requestId: requestId, result: result) return true } - private func respondWithNoDecisionApproval( - method: String, - params: [String: Any], - requestId: Any, - connection: CodexTeamsAppServerConnection - ) throws { - guard let result = CMUXCLI.codexTeamsAppServerNoDecisionApprovalResponse( - method: method, - params: params - ) else { - try connection.respondError( - requestId: requestId, - code: -32601, - message: "cmux Codex Teams watcher cannot respond to \(method)" - ) - return - } - try connection.respond(requestId: requestId, result: result) - } - private func approvalSuppressionKey(method: String, requestId: Any, params: [String: Any]) -> String { let stableId = CMUXCLI.stringValue( in: params, @@ -18802,16 +18778,6 @@ struct CMUXCLI { ) } - static func codexTeamsAppServerNoDecisionApprovalResponse( - method: String, - params: [String: Any] - ) -> [String: Any]? { - CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( - method: method, - params: params - ) - } - static func codexTeamsApprovalItemSnapshot(_ item: [String: Any]) -> [String: Any] { CodexTeamsApprovalBridge.approvalItemSnapshot(item) } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 50523793fef2..bbfb0fb99aba 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -400,41 +400,6 @@ struct FeedCoordinatorTests { #expect(CodexTeamsApprovalBridge.permissionMode(fromFeedPushResponse: ["status": "timed_out"]) == nil) } - @Test func codexAppServerNoDecisionApprovalResponseKeepsWatcherNonBlocking() throws { - let commandCancel = try #require( - CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( - method: "item/commandExecution/requestApproval", - params: [:] - ) - ) - #expect(commandCancel["decision"] as? String == "cancel") - - let commandDecline = try #require( - CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( - method: "item/commandExecution/requestApproval", - params: ["availableDecisions": ["accept", "decline"]] - ) - ) - #expect(commandDecline["decision"] as? String == "decline") - - let fileCancel = try #require( - CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( - method: "item/fileChange/requestApproval", - params: ["availableDecisions": ["accept", "acceptForSession", "cancel"]] - ) - ) - #expect(fileCancel["decision"] as? String == "cancel") - - let permissionsDeny = try #require( - CodexTeamsApprovalBridge.appServerNoDecisionApprovalResponse( - method: "item/permissions/requestApproval", - params: ["permissions": ["network": ["enabled": true]]] - ) - ) - #expect(permissionsDeny["scope"] as? String == "turn") - #expect((permissionsDeny["permissions"] as? [String: Any])?.isEmpty == true) - } - @Test func codexApprovalItemSnapshotStripsLargePayloads() throws { let snapshot = CodexTeamsApprovalBridge.approvalItemSnapshot([ "id": "call-1",