diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 9a9a020ae8dd..43e9a35a82ab 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -245,6 +245,63 @@ extension CMUXCLI { return agentDefs.first { $0.name == normalized || $0.aliases.contains(normalized) } } + static func hookCommandString(for def: AgentHookDef, event: AgentHookDef.HookEvent) -> String { + "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && cmux hooks \(def.name) \(event.cmuxSubcommand) || echo '{}'" + } + + static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { + "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && cmux hooks feed --source \(def.name) --event \(agentEvent) || echo '{}'" + } + + static func isCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef, includeLegacy: Bool = true) -> Bool { + if def.events.contains(where: { hookCommandString(for: def, event: $0) == command }) + || def.feedHookEvents.contains(where: { feedHookCommandString(for: def, agentEvent: $0) == command }) + { + return true + } + return includeLegacy && isLegacyCmuxOwnedHookCommand(command, for: def) + } + + private static func isLegacyCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef) -> Bool { + // Legacy cmux codex-hook and feed-hook commands only existed for Codex hooks. + guard def.name == "codex" else { + return false + } + let tokens = legacyCmuxCommandTokens(from: command, for: def) + guard !tokens.isEmpty, + URL(fileURLWithPath: String(tokens[0])).lastPathComponent == "cmux" + else { + return false + } + + if tokens.count >= 2, tokens[1] == "codex-hook" { + return true + } + if tokens.count >= 4, tokens[1] == "feed-hook", tokens[2] == "--source", tokens[3] == def.name { + return true + } + if tokens.count >= 5, tokens[1] == "hooks", tokens[2] == "feed", tokens[3] == "--source", tokens[4] == def.name { + return true + } + return false + } + + private static func legacyCmuxCommandTokens(from command: String, for def: AgentHookDef) -> [Substring] { + let guardedPrefix = "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && " + let fallbackSuffix = " || echo '{}'" + var body = command + if body.hasPrefix(guardedPrefix) { + body.removeFirst(guardedPrefix.count) + } + if body.hasSuffix(fallbackSuffix) { + body.removeLast(fallbackSuffix.count) + } + guard !body.contains(";"), !body.contains("|"), !body.contains("&"), !body.contains("`") else { + return [] + } + return body.split(whereSeparator: { $0 == " " || $0 == "\t" }) + } + static func hookMarkers(for def: AgentHookDef) -> [String] { var markers = [def.hookMarker] if def.name == "codex" { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a88260c17d13..b31694365ed2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16007,6 +16007,11 @@ struct CMUXCLI { let isStreamError: Bool } + private struct CodexHookUserInputCandidate { + let callId: String + let question: String? + } + private enum CodexTranscriptFailureReadResult { case unavailable case pending @@ -16208,6 +16213,184 @@ struct CMUXCLI { return .healthy } + private func readCodexTranscriptUserInput( + path: String, + turnId: String?, + excluding publishedCallIds: Set + ) -> CodexHookUserInputCandidate? { + guard let content = readTextFileTail(path: path, maxBytes: 512 * 1024) else { + return nil + } + + var sawRelevantTurn = turnId == nil + var candidate: CodexHookUserInputCandidate? + for line in content.split(separator: "\n", omittingEmptySubsequences: true) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let objectType = object["type"] as? String else { + continue + } + + if objectType == "turn_context", + let payload = object["payload"] as? [String: Any] { + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let turnId { + sawRelevantTurn = payloadTurnId == turnId + } else { + sawRelevantTurn = true + } + continue + } + + if objectType == "response_item", + let payload = object["payload"] as? [String: Any], + let userInput = codexUserInputFunctionCallCandidate( + from: payload, + turnId: turnId, + sawRelevantTurn: sawRelevantTurn, + excluding: publishedCallIds + ) { + candidate = userInput + continue + } + + guard objectType == "event_msg", + let payload = object["payload"] as? [String: Any], + let eventType = payload["type"] as? String else { + continue + } + + switch eventType { + case "task_started": + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let turnId { + sawRelevantTurn = payloadTurnId == turnId + } else { + sawRelevantTurn = true + } + + case "request_user_input": + if let userInput = codexUserInputEventCandidate( + from: payload, + turnId: turnId, + sawRelevantTurn: sawRelevantTurn, + excluding: publishedCallIds + ) { + candidate = userInput + } + + case "task_complete", "turn_complete": + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let turnId { + guard payloadTurnId == turnId else { continue } + } + sawRelevantTurn = true + candidate = nil + + default: + break + } + } + + return candidate + } + + private func codexUserInputEventCandidate( + from payload: [String: Any], + turnId: String?, + sawRelevantTurn: Bool, + excluding publishedCallIds: Set + ) -> CodexHookUserInputCandidate? { + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + if let turnId { + if let payloadTurnId { + guard payloadTurnId == turnId else { return nil } + } else { + guard sawRelevantTurn else { return nil } + } + } + return codexUserInputCandidate( + from: payload, + payloadTurnId: payloadTurnId, + turnId: turnId, + excluding: publishedCallIds + ) + } + + private func codexUserInputFunctionCallCandidate( + from payload: [String: Any], + turnId: String?, + sawRelevantTurn: Bool, + excluding publishedCallIds: Set + ) -> CodexHookUserInputCandidate? { + guard (payload["type"] as? String) == "function_call", + (payload["name"] as? String) == "request_user_input" else { + return nil + } + + let arguments = codexFunctionCallArgumentsObject(from: payload) + let payloadTurnId = firstString(in: payload, keys: ["turn_id", "turnId"]) + ?? arguments.flatMap { firstString(in: $0, keys: ["turn_id", "turnId"]) } + if let turnId { + if let payloadTurnId { + guard payloadTurnId == turnId else { return nil } + } else { + guard sawRelevantTurn else { return nil } + } + } + + return codexUserInputCandidate( + from: arguments ?? payload, + payloadTurnId: payloadTurnId, + turnId: turnId, + fallbackCallId: firstString(in: payload, keys: ["call_id", "callId"]), + excluding: publishedCallIds + ) + } + + private func codexFunctionCallArgumentsObject(from payload: [String: Any]) -> [String: Any]? { + if let arguments = payload["arguments"] as? [String: Any] { + return arguments + } + guard let rawArguments = payload["arguments"] as? String, + let data = rawArguments.data(using: .utf8), + let arguments = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + return nil + } + return arguments + } + + private func codexUserInputCandidate( + from payload: [String: Any], + payloadTurnId: String?, + turnId: String?, + fallbackCallId: String? = nil, + excluding publishedCallIds: Set + ) -> CodexHookUserInputCandidate? { + let question = codexUserInputQuestionText(from: payload) + let rawCallId = firstString(in: payload, keys: ["call_id", "callId"]) ?? fallbackCallId + let callId = rawCallId?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "\(payloadTurnId ?? turnId ?? "session"):\(question ?? "request_user_input")" + guard !publishedCallIds.contains(callId) else { return nil } + return CodexHookUserInputCandidate(callId: callId, question: question) + } + + private func codexUserInputQuestionText(from payload: [String: Any]) -> String? { + guard let questions = payload["questions"] as? [[String: Any]] else { + return nil + } + for question in questions { + let text = firstString(in: question, keys: ["question", "header", "id"]) + let normalized = text.map(normalizedSingleLine)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalized, !normalized.isEmpty { + return truncate(normalized, maxLength: 220) + } + } + return nil + } + private func codexHookStopPayloadHasAssistantMessage(_ object: [String: Any]?) -> Bool { guard let object, let message = firstString(in: object, keys: ["last_assistant_message", "lastAssistantMessage"]) else { @@ -16668,6 +16851,7 @@ struct CMUXCLI { defer { removeCodexMonitorLease(path: leasePath) } let deadline = Date().addingTimeInterval(4 * 60 * 60) var nextOwnerCheck = Date.distantPast + var publishedUserInputCallIds = Set() while Date() < deadline { if isCodexMonitorLeaseRetired(path: leasePath) { return @@ -16685,6 +16869,20 @@ struct CMUXCLI { } if let currentTranscriptPath = transcriptPath { + if let userInput = readCodexTranscriptUserInput( + path: currentTranscriptPath, + turnId: turnId, + excluding: publishedUserInputCallIds + ) { + publishedUserInputCallIds.insert(userInput.callId) + publishCodexMonitorUserInput( + userInput, + workspaceId: workspaceId, + surfaceId: surfaceId, + client: client + ) + } + switch readCodexTranscriptFailure( path: currentTranscriptPath, turnId: turnId, @@ -16720,6 +16918,28 @@ struct CMUXCLI { } } + private func publishCodexMonitorUserInput( + _ userInput: CodexHookUserInputCandidate, + workspaceId: String, + surfaceId: String?, + client: SocketClient + ) { + let subtitle = String(localized: "agent.codex.input.subtitle.waiting", defaultValue: "Waiting") + let body = userInput.question ?? String( + localized: "agent.codex.input.body.needsInput", + defaultValue: "Codex is asking a question" + ) + if let surfaceId, !surfaceId.isEmpty { + let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(sanitizeNotificationField(body))" + _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + } + let statusValue = String(localized: "agent.codex.input.status.needsInput", defaultValue: "Codex needs input") + _ = try? sendV1Command( + "set_status codex \(statusValue) --icon=bell.fill --color=#4C8DFF --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } + private func publishCodexMonitorFailure( _ failure: CodexHookFailureCandidate, workspaceId: String, @@ -17164,7 +17384,7 @@ struct CMUXCLI { // MARK: Generic hook install/uninstall func hookCommand(for def: AgentHookDef, event: AgentHookDef.HookEvent) -> String { - "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && cmux hooks \(def.name) \(event.cmuxSubcommand) || echo '{}'" + Self.hookCommandString(for: def, event: event) } /// Shell command the agent runs for a Feed bridge event. 120s timeout @@ -17172,7 +17392,7 @@ struct CMUXCLI { /// nested hook config (see `buildHooksDict`); the shell command /// itself just dispatches. func feedHookCommand(for def: AgentHookDef, agentEvent: String) -> String { - "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && cmux hooks feed --source \(def.name) --event \(agentEvent) || echo '{}'" + Self.feedHookCommandString(for: def, agentEvent: agentEvent) } private func buildHooksDict(for def: AgentHookDef) -> [String: Any] { @@ -17866,32 +18086,50 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { // Remove existing cmux-owned entries (both the per-agent hook // dispatcher and the Feed bridge). Non-cmux entries are - // always preserved — even when the user mixed them into the + // always preserved, even when the user mixed them into the // same group as a cmux hook, we only prune our own entries // within that group so the user's stays put. let isCmuxOwnedCommand: (String) -> Bool = { cmd in - Self.hookMarkers(for: def).contains { cmd.contains($0) } - || Self.feedHookMarkers(for: def).contains { cmd.contains($0) } + Self.isCmuxOwnedHookCommand(cmd, for: def) } + var cmuxInsertionIndexes: [String: [Int]] = [:] for (event, value) in hooks { switch def.format { case .flat: - guard var entries = value as? [[String: Any]] else { continue } - entries.removeAll { isCmuxOwnedCommand($0["command"] as? String ?? "") } - hooks[event] = entries.isEmpty ? nil : entries + guard let entries = value as? [[String: Any]] else { continue } + var rewrittenEntries: [[String: Any]] = [] + for entry in entries { + if isCmuxOwnedCommand(entry["command"] as? String ?? "") { + Self.appendCmuxHookInsertionIndex( + rewrittenEntries.count, + for: event, + to: &cmuxInsertionIndexes + ) + continue + } + rewrittenEntries.append(entry) + } + hooks[event] = rewrittenEntries.isEmpty ? nil : rewrittenEntries case .nested: guard let groups = value as? [[String: Any]] else { continue } var rewrittenGroups: [[String: Any]] = [] for var group in groups { guard var hookList = group["hooks"] as? [[String: Any]] else { - // Unknown shape — preserve verbatim so we don't + // Unknown shape: preserve verbatim so we don't // accidentally mutate user custom data. rewrittenGroups.append(group) continue } + if hookList.contains(where: { isCmuxOwnedCommand($0["command"] as? String ?? "") }) { + Self.appendCmuxHookInsertionIndex( + rewrittenGroups.count, + for: event, + to: &cmuxInsertionIndexes + ) + } hookList.removeAll { isCmuxOwnedCommand($0["command"] as? String ?? "") } if hookList.isEmpty { - // Fully cmux-owned group → drop it entirely. + // Fully cmux-owned group, drop it entirely. continue } group["hooks"] = hookList @@ -17908,11 +18146,23 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { switch def.format { case .flat: var entries = hooks[event] as? [[String: Any]] ?? [] - if let newEntries = value as? [[String: Any]] { entries.append(contentsOf: newEntries) } + if let newEntries = value as? [[String: Any]] { + if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) + } else { + entries.append(contentsOf: newEntries) + } + } hooks[event] = entries case .nested: var groups = hooks[event] as? [[String: Any]] ?? [] - if let newGroups = value as? [[String: Any]] { groups.append(contentsOf: newGroups) } + if let newGroups = value as? [[String: Any]] { + if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + Self.insertCmuxHookValues(newGroups, into: &groups, atOriginalIndexes: insertionIndexes) + } else { + groups.append(contentsOf: newGroups) + } + } hooks[event] = groups case .rovoDevYAML, .hermesAgentYAML: break @@ -17921,6 +18171,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { existing["hooks"] = hooks if case .flat = def.format { existing["version"] = 1 } + let codexHookTrustEntries = Self.codexHookTrustEntries( + hooks: hooks, + hooksFilePath: filePath, + def: def + ) let newData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) let newString = String(data: newData, encoding: .utf8) ?? "{}" @@ -17969,7 +18224,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } else { existingContent = "" } - let newContent = Self.codexConfigTomlInstallingHooksFeature(in: existingContent) + let featureContent = Self.codexConfigTomlInstallingHooksFeature(in: existingContent) + let trustInstall = Self.codexConfigTomlInstallingHookTrust( + in: featureContent, + entries: codexHookTrustEntries + ) + let newContent = trustInstall.content if newContent != existingContent { if !skipConfirm { Self.printInstallPreview( @@ -17985,7 +18245,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) - print("Enabled codex_hooks in \(configPath)") + if def.name == "codex", !codexHookTrustEntries.isEmpty, trustInstall.installedTrust { + print("Enabled hooks and approved cmux hooks in \(configPath)") + } else { + print("Enabled hooks in \(configPath)") + } } } } @@ -18027,8 +18291,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { var removed = 0 let isCmuxOwnedCommand: (String) -> Bool = { cmd in - Self.hookMarkers(for: def).contains { cmd.contains($0) } - || Self.feedHookMarkers(for: def).contains { cmd.contains($0) } + Self.isCmuxOwnedHookCommand(cmd, for: def) } for (event, value) in hooks { switch def.format { @@ -18069,8 +18332,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { switch action { case .codexConfigToml: let configPath = "\(configDir)/config.toml" - guard fm.fileExists(atPath: configPath), - let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return } + guard fm.fileExists(atPath: configPath) else { return } + let content: String + do { + content = try String(contentsOfFile: configPath, encoding: .utf8) + } catch { + throw CLIError(message: "\(configPath) exists but could not be read. Fix permissions or remove it before uninstalling \(def.displayName) hooks. \(String(describing: error))") + } let newContent = Self.codexConfigTomlUninstallingHooksFeature(from: content) if newContent != content { try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) @@ -18080,6 +18348,24 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } + private static func appendCmuxHookInsertionIndex( + _ index: Int, + for event: String, + to indexes: inout [String: [Int]] + ) { + if indexes[event]?.last == index { return } + indexes[event, default: []].append(index) + } + + private static func insertCmuxHookValues(_ values: [T], into target: inout [T], atOriginalIndexes indexes: [Int]) { + var insertedCount = 0 + for originalIndex in indexes { + let insertionIndex = min(max(originalIndex + insertedCount, 0), target.count) + target.insert(contentsOf: values, at: insertionIndex) + insertedCount += values.count + } + } + private static let cmuxCodexHooksFeatureBegin = "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin" private static let cmuxCodexHooksFeatureEnd = @@ -18090,130 +18376,309 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { private static let legacyCmuxCodexHooksFeatureEnd = "# cmux hooks codex feature end" private static let legacyCmuxCodexHooksFeaturePreviousLinePrefix = "# cmux hooks codex feature previous line: " + private static let cmuxCodexHookTrustBegin = + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin" + private static let cmuxCodexHookTrustEnd = + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end" + + struct CodexHookTrustEntry: Equatable { + let key: String + let trustedHash: String + } + + private struct CodexHookTrustInstallResult: Equatable { + let content: String + let installedTrust: Bool + } + + private enum CodexHookTrustBlockRemovalResult { + case notFound + case removed + case malformed + } - private static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String { + static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String { var lines = tomlLines(from: existingContent) - removeLegacyCodexHooksFeatureBlock(from: &lines) - var currentTable: String? - for index in lines.indices { - let line = lines[index] - if let tableName = tomlTableName(in: line) { - currentTable = tableName - continue - } - if (currentTable == nil || currentTable == "features"), - tomlLineDefinesCodexHooksKey(line) { - lines[index] = "codex_hooks = true" - return tomlContent(from: lines) - } - if currentTable == nil, tomlLineDefinesDottedCodexHooksKey(line) { - lines[index] = "features.codex_hooks = true" - return tomlContent(from: lines) - } + removeCmuxCodexHooksFeatureBlock(from: &lines) + if removeCmuxCodexHookTrustBlock(from: &lines) == .malformed { + stripMalformedCmuxCodexHookTrustMarker(from: &lines) } + lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } + lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } + + let insertedLines = [ + cmuxCodexHooksFeatureBegin, + "hooks = true", + cmuxCodexHooksFeatureEnd, + ] + let insertedDottedLines = [ + cmuxCodexHooksFeatureBegin, + "features.hooks = true", + cmuxCodexHooksFeatureEnd, + ] if let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) { - lines.insert("codex_hooks = true", at: featuresStart + 1) + let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart) + if featuresStart + 1 < featuresEnd, + let hooksIndex = (featuresStart + 1.. [String] { + var lines = [cmuxCodexHooksFeatureBegin] + if let previousLine { + lines.append(cmuxCodexHooksFeaturePreviousLinePrefix + previousLine) } + lines.append(settingLine) + lines.append(cmuxCodexHooksFeatureEnd) + return lines + } + + static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String { + var lines = tomlLines(from: existingContent) + removeCmuxCodexHooksFeatureBlock(from: &lines) + if removeCmuxCodexHookTrustBlock(from: &lines) == .malformed { + stripMalformedCmuxCodexHookTrustMarker(from: &lines) + } + lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } + lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } + removeEmptyFeaturesTable(from: &lines) return tomlContent(from: lines) } - private static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String { + private static func codexConfigTomlInstallingHookTrust( + in existingContent: String, + entries: [CodexHookTrustEntry] + ) -> CodexHookTrustInstallResult { var lines = tomlLines(from: existingContent) - removeLegacyCodexHooksFeatureBlock(from: &lines) - var filteredLines: [String] = [] - var currentTable: String? - for line in lines { - if let tableName = tomlTableName(in: line) { - currentTable = tableName - filteredLines.append(line) - continue - } - if (currentTable == nil || currentTable == "features"), - tomlLineDefinesCodexHooksKey(line) { + let removalResult = removeCmuxCodexHookTrustBlock(from: &lines) + if removalResult == .malformed { + stripMalformedCmuxCodexHookTrustMarker(from: &lines) + } + guard !entries.isEmpty else { + return CodexHookTrustInstallResult(content: tomlContent(from: lines), installedTrust: false) + } + removeCodexHookTrustTables( + withEscapedKeys: Set(entries.map { tomlBasicStringContent($0.key) }), + from: &lines + ) + + if !lines.isEmpty, lines.last?.isEmpty == false { + lines.append("") + } + lines.append(cmuxCodexHookTrustBegin) + for entry in entries { + lines.append("[hooks.state.\"\(tomlBasicStringContent(entry.key))\"]") + lines.append("trusted_hash = \"\(tomlBasicStringContent(entry.trustedHash))\"") + } + lines.append(cmuxCodexHookTrustEnd) + return CodexHookTrustInstallResult(content: tomlContent(from: lines), installedTrust: true) + } + + static func codexHookTrustEntries( + hooks: [String: Any], + hooksFilePath: String, + def: AgentHookDef + ) -> [CodexHookTrustEntry] { + guard def.name == "codex" else { return [] } + let isOwnedCommand: (String) -> Bool = { command in + isCmuxOwnedHookCommand(command, for: def, includeLegacy: false) + } + var entries: [CodexHookTrustEntry] = [] + let keySource = codexNormalizedHookSourcePath(hooksFilePath) + + for eventName in codexHookEventNames { + guard let groups = hooks[eventName] as? [[String: Any]], + let eventLabel = codexHookEventLabel(eventName) else { continue } - if currentTable == nil, tomlLineDefinesDottedCodexHooksKey(line) { - continue + for (groupIndex, group) in groups.enumerated() { + guard let hookList = group["hooks"] as? [[String: Any]] else { continue } + let matcher = codexHookEventUsesMatcher(eventName) ? group["matcher"] as? String : nil + for (handlerIndex, hook) in hookList.enumerated() { + guard let command = hook["command"] as? String, + isOwnedCommand(command) else { + continue + } + let timeoutMs = max(intValue(hook["timeout"]) ?? 600, 1) + let statusMessage = hook["statusMessage"] as? String + let key = "\(keySource):\(eventLabel):\(groupIndex):\(handlerIndex)" + let trustedHash = codexCommandHookHash( + eventLabel: eventLabel, + matcher: matcher, + command: command, + timeoutMs: timeoutMs, + statusMessage: statusMessage + ) + entries.append(CodexHookTrustEntry(key: key, trustedHash: trustedHash)) + } } - filteredLines.append(line) } - removeEmptyFeaturesTable(from: &filteredLines) - return tomlContent(from: filteredLines) + + return entries } - private static func removeEmptyFeaturesTable(from lines: inout [String]) { - var index = 0 - while index < lines.count { - guard tomlLineIsTable("features", line: lines[index]) else { - index += 1 - continue - } + private static func codexNormalizedHookSourcePath(_ path: String) -> String { + let url = URL(fileURLWithPath: path).standardizedFileURL + if let resolved = realPath(url.path) { + return resolved + } + let parent = url.deletingLastPathComponent() + if let resolvedParent = realPath(parent.path) { + return URL(fileURLWithPath: resolvedParent, isDirectory: true) + .appendingPathComponent(url.lastPathComponent) + .path + } + return url.path + } - let bodyStart = index + 1 - var bodyEnd = bodyStart - while bodyEnd < lines.count, tomlTableName(in: lines[bodyEnd]) == nil { - bodyEnd += 1 - } + private static func realPath(_ path: String) -> String? { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard realpath(path, &buffer) != nil else { return nil } + return String(cString: buffer) + } - let bodyIsEmpty = lines[bodyStart.. 0, - lines[index - 1].trimmingCharacters(in: .whitespaces).isEmpty - { - lines.remove(at: index - 1) - } else if index > 0, index < lines.count, - lines[index - 1].trimmingCharacters(in: .whitespaces).isEmpty, - lines[index].trimmingCharacters(in: .whitespaces).isEmpty - { - lines.remove(at: index) - } + private static func codexHookEventLabel(_ eventName: String) -> String? { + switch eventName { + case "PreToolUse": return "pre_tool_use" + case "PermissionRequest": return "permission_request" + case "PostToolUse": return "post_tool_use" + case "PreCompact": return "pre_compact" + case "PostCompact": return "post_compact" + case "SessionStart": return "session_start" + case "UserPromptSubmit": return "user_prompt_submit" + case "Stop": return "stop" + default: return nil } } - private static func removeLegacyCodexHooksFeatureBlock(from lines: inout [String]) { - var index = 0 - while index < lines.count { - guard tomlLineIsLegacyCodexHooksFeatureBegin(lines[index]) else { - index += 1 - continue - } + private static func codexHookEventUsesMatcher(_ eventName: String) -> Bool { + switch eventName { + case "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart": + return true + default: + return false + } + } - if let endIndex = lines[index...].firstIndex(where: { - tomlLineIsLegacyCodexHooksFeatureEnd($0) - }) { - let restoredLines = lines[index...endIndex].compactMap { line -> String? in - tomlLegacyCodexHooksFeaturePreviousLine(from: line) - } - lines.replaceSubrange(index...endIndex, with: restoredLines) - } else { - var blockEnd = index + 1 - var restoredLines: [String] = [] - if blockEnd < lines.count, - let previousLine = tomlLegacyCodexHooksFeaturePreviousLine(from: lines[blockEnd]) - { - restoredLines.append(previousLine) - blockEnd += 1 - } - if blockEnd < lines.count, tomlLineIsLegacyCodexHooksFeatureSetting(lines[blockEnd]) { - blockEnd += 1 + private static func codexCommandHookHash( + eventLabel: String, + matcher: String?, + command: String, + timeoutMs: Int, + statusMessage: String? + ) -> String { + let normalizedTimeoutMs = max(timeoutMs, 1) + var handler: [String: Any] = [ + "async": false, + "command": command, + "timeout": normalizedTimeoutMs, + "type": "command", + ] + if let statusMessage { + handler["statusMessage"] = statusMessage + } + var identity: [String: Any] = [ + "event_name": eventLabel, + "hooks": [handler], + ] + if let matcher { + identity["matcher"] = matcher + } + + let data = (try? JSONSerialization.data( + withJSONObject: identity, + options: [.sortedKeys, .withoutEscapingSlashes] + )) ?? Data() + let digest = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + return "sha256:\(digest)" + } + + private static func intValue(_ value: Any?) -> Int? { + if let intValue = value as? Int { + return intValue + } + if let number = value as? NSNumber { + return number.intValue + } + return nil + } + + private static func tomlBasicStringContent(_ value: String) -> String { + var escaped = "" + escaped.reserveCapacity(value.count) + + for scalar in value.unicodeScalars { + switch scalar.value { + case 0x08: + escaped += "\\b" + case 0x09: + escaped += "\\t" + case 0x0A: + escaped += "\\n" + case 0x0C: + escaped += "\\f" + case 0x0D: + escaped += "\\r" + case 0x22: + escaped += "\\\"" + case 0x5C: + escaped += "\\\\" + case 0x00...0x1F, 0x7F...0x9F: + if scalar.value <= 0xFFFF { + escaped += String(format: "\\u%04X", scalar.value) + } else { + escaped += String(format: "\\U%08X", scalar.value) } - lines.replaceSubrange(index.. [String] { @@ -18230,47 +18695,41 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return lines.joined(separator: "\n") + "\n" } - private static func tomlLineDefinesCodexHooksKey(_ line: String) -> Bool { - line.range( - of: #"^\s*codex_hooks\s*="#, + private static func tomlLineDefinesKey(_ key: String, line: String) -> Bool { + let escapedKey = NSRegularExpression.escapedPattern(for: key) + return line.range( + of: #"^\s*"# + escapedKey + #"\s*="#, options: .regularExpression ) != nil } - private static func tomlLineDefinesDottedCodexHooksKey(_ line: String) -> Bool { - line.range( - of: #"^\s*features\s*\.\s*codex_hooks\s*="#, + private static func tomlLineDefinesTrueKey(_ key: String, line: String) -> Bool { + let escapedKey = NSRegularExpression.escapedPattern(for: key) + return line.range( + of: #"^\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#, options: .regularExpression ) != nil } - private static func tomlLineIsLegacyCodexHooksFeatureBegin(_ line: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed == cmuxCodexHooksFeatureBegin || trimmed == legacyCmuxCodexHooksFeatureBegin - } - - private static func tomlLineIsLegacyCodexHooksFeatureEnd(_ line: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed == cmuxCodexHooksFeatureEnd || trimmed == legacyCmuxCodexHooksFeatureEnd + private static func tomlLineDefinesDottedFeaturesKey(_ key: String, line: String) -> Bool { + let escapedKey = NSRegularExpression.escapedPattern(for: key) + return line.range( + of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*="#, + options: .regularExpression + ) != nil } - private static func tomlLegacyCodexHooksFeaturePreviousLine(from line: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) { - return String(trimmed.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count)) - } - if trimmed.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) { - return String(trimmed.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count)) - } - return nil + private static func tomlLineDefinesDottedFeaturesTrueKey(_ key: String, line: String) -> Bool { + let escapedKey = NSRegularExpression.escapedPattern(for: key) + return line.range( + of: #"^\s*features\s*\.\s*"# + escapedKey + #"\s*=\s*true\s*(#.*)?$"#, + options: .regularExpression + ) != nil } - private static func tomlLineIsLegacyCodexHooksFeatureSetting(_ line: String) -> Bool { + private static func tomlLineDefinesAnyDottedFeaturesKey(_ line: String) -> Bool { line.range( - of: #"^\s*hooks\s*=\s*true\s*(#.*)?$"#, - options: .regularExpression - ) != nil || line.range( - of: #"^\s*features\s*\.\s*hooks\s*=\s*true\s*(#.*)?$"#, + of: #"^\s*features\s*\.\s*[^=\s]+\s*="#, options: .regularExpression ) != nil } @@ -18283,18 +18742,157 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) != nil } - private static func tomlTableName(in line: String) -> String? { - let pattern = #"^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(#.*)?$"# - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch( - in: line, - range: NSRange(line.startIndex..., in: line) - ), - match.numberOfRanges > 1, - let range = Range(match.range(at: 1), in: line) else { + private static func tomlLineIsAnyTableHeader(_ line: String) -> Bool { + let tomlKey = "(?:[A-Za-z0-9_-]+|\"[^\"\\n]*\"|'[^'\\n]*')" + let tomlKeyPath = tomlKey + "(?:\\s*\\.\\s*" + tomlKey + ")*" + let pattern = "^\\s*(?:\\[\\s*" + tomlKeyPath + "\\s*\\]|\\[\\[\\s*" + tomlKeyPath + + "\\s*\\]\\])\\s*(#.*)?$" + return line.range( + of: pattern, + options: .regularExpression + ) != nil + } + + private static func tomlTableEndIndex(in lines: [String], after tableStart: Int) -> Int { + var index = tableStart + 1 + while index < lines.count { + if tomlLineIsAnyTableHeader(lines[index]) { + return index + } + index += 1 + } + return lines.count + } + + private static func removeCmuxCodexHooksFeatureBlock(from lines: inout [String]) { + var index = 0 + while index < lines.count { + guard tomlLineIsCodexHooksFeatureBegin(lines[index]) else { + index += 1 + continue + } + + if let endIndex = lines[index...].firstIndex(where: { + tomlLineIsCodexHooksFeatureEnd($0) + }) { + let previousLines = lines[index...endIndex].compactMap { line -> String? in + tomlCodexHooksFeaturePreviousLine(from: line) + } + lines.replaceSubrange(index...endIndex, with: previousLines) + } else { + var blockEnd = index + 1 + var previousLines: [String] = [] + if blockEnd < lines.count, + let previousLine = tomlCodexHooksFeaturePreviousLine(from: lines[blockEnd]) + { + previousLines.append(previousLine) + blockEnd += 1 + } + if blockEnd < lines.count, tomlLineIsCodexHooksFeatureSetting(lines[blockEnd]) { + blockEnd += 1 + } + lines.replaceSubrange(index.. CodexHookTrustBlockRemovalResult { + var ranges: [ClosedRange] = [] + var index = 0 + while index < lines.count { + guard lines[index] == cmuxCodexHookTrustBegin else { + index += 1 + continue + } + + guard let endIndex = lines[index...].firstIndex(of: cmuxCodexHookTrustEnd) else { + return .malformed + } + ranges.append(index...endIndex) + index = endIndex + 1 + } + + for range in ranges.reversed() { + lines.removeSubrange(range) + } + return ranges.isEmpty ? .notFound : .removed + } + + private static func stripMalformedCmuxCodexHookTrustMarker(from lines: inout [String]) { + lines.removeAll { $0 == cmuxCodexHookTrustBegin } + } + + private static func removeCodexHookTrustTables(withEscapedKeys keys: Set, from lines: inout [String]) { + guard !keys.isEmpty else { return } + var index = 0 + while index < lines.count { + guard let escapedKey = codexHookTrustTableEscapedKey(from: lines[index]), + keys.contains(escapedKey) else { + index += 1 + continue + } + let endIndex = tomlTableEndIndex(in: lines, after: index) + lines.removeSubrange(index.. String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let prefix = "[hooks.state.\"" + let suffix = "\"]" + guard trimmed.hasPrefix(prefix), trimmed.hasSuffix(suffix) else { return nil } - return String(line[range]) + return String(trimmed.dropFirst(prefix.count).dropLast(suffix.count)) + } + + private static func tomlLineIsCodexHooksFeatureBegin(_ line: String) -> Bool { + line == cmuxCodexHooksFeatureBegin || line == legacyCmuxCodexHooksFeatureBegin + } + + private static func tomlLineIsCodexHooksFeatureEnd(_ line: String) -> Bool { + line == cmuxCodexHooksFeatureEnd || line == legacyCmuxCodexHooksFeatureEnd + } + + private static func tomlCodexHooksFeaturePreviousLine(from line: String) -> String? { + if line.hasPrefix(cmuxCodexHooksFeaturePreviousLinePrefix) { + return String(line.dropFirst(cmuxCodexHooksFeaturePreviousLinePrefix.count)) + } + if line.hasPrefix(legacyCmuxCodexHooksFeaturePreviousLinePrefix) { + return String(line.dropFirst(legacyCmuxCodexHooksFeaturePreviousLinePrefix.count)) + } + return nil + } + + private static func tomlLineIsCodexHooksFeatureSetting(_ line: String) -> Bool { + tomlLineDefinesTrueKey("hooks", line: line) + || tomlLineDefinesDottedFeaturesTrueKey("hooks", line: line) + } + + private static func removeEmptyFeaturesTable(from lines: inout [String]) { + guard let featuresStart = lines.firstIndex(where: { tomlLineIsTable("features", line: $0) }) else { + return + } + let featuresEnd = tomlTableEndIndex(in: lines, after: featuresStart) + let bodyRange = featuresStart + 1.. 0, + lines[featuresStart - 1].trimmingCharacters(in: .whitespaces).isEmpty + { + lines.remove(at: featuresStart - 1) + } else if featuresStart > 0, featuresStart < lines.count, + lines[featuresStart - 1].trimmingCharacters(in: .whitespaces).isEmpty, + lines[featuresStart].trimmingCharacters(in: .whitespaces).isEmpty + { + lines.remove(at: featuresStart) + } + } } // MARK: Generic hook handler diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 60ef62dc1e42..4ec13ce17b91 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -20635,6 +20635,57 @@ } } }, + "agent.codex.input.body.needsInput": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex is asking a question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex が質問しています" + } + } + } + }, + "agent.codex.input.status.needsInput": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex needs input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex 入力待ち" + } + } + } + }, + "agent.codex.input.subtitle.waiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + } + } + }, "agent.codex.status.idle": { "extractionState": "manual", "localizations": { diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 6aaadcdd52ea..5de2accb9467 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1675,12 +1675,40 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { private final class MockSocketServerState: @unchecked Sendable { private let lock = NSLock() + private let commandSemaphore = DispatchSemaphore(value: 0) private(set) var commands: [String] = [] func append(_ command: String) { lock.lock() commands.append(command) lock.unlock() + commandSemaphore.signal() + } + + func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return commands + } + + func waitForCommand(timeout: TimeInterval, matching predicate: (String) -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while true { + lock.lock() + let matched = commands.contains(where: predicate) + lock.unlock() + if matched { + return true + } + + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + return false + } + if commandSemaphore.wait(timeout: .now() + remaining) == .timedOut { + return snapshot().contains(where: predicate) + } + } } } @@ -1715,6 +1743,14 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { return false } + private func waitForSocketCommand( + state: MockSocketServerState, + timeout: TimeInterval, + matching predicate: (String) -> Bool + ) -> Bool { + state.waitForCommand(timeout: timeout, matching: predicate) + } + private func bundledCLIPath() throws -> String { let fileManager = FileManager.default let appBundleURL = Bundle(for: Self.self) @@ -2938,6 +2974,206 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + func testCodexHookMonitorNotifiesOnRequestUserInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-session-monitor-user-input" + let turnId = "turn-monitor-user-input" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"event_msg","payload":{"type":"task_started","turn_id":"\(turnId)","started_at":1777107522}} + {"timestamp":"2026-04-25T07:55:29.700Z","type":"event_msg","payload":{"type":"request_user_input","call_id":"call-plan-question","turn_id":"\(turnId)","questions":[{"id":"demo_path","header":"Demo","question":"Which demo path should I use?","options":[{"label":"Plan","description":"Show plan mode"}]}]}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + _ = startMockServerSignal(listenerFD: listenerFD, state: state) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + defer { + if process.isRunning { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + _ = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + _ = stderrPipe.fileHandleForReading.readDataToEndOfFile() + } + + XCTAssertTrue( + waitForProcess(process, toHoldOpenFile: transcriptURL.path, timeout: 2), + "Monitor did not start watching the request_user_input transcript" + ) + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.contains("notify_target \(workspaceId) \(surfaceId) Codex|Waiting|Which demo path should I use?") + }, + "Expected monitor to send Codex input notification, saw \(state.snapshot())" + ) + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.contains("set_status codex Codex needs input") && + command.contains("--icon=bell.fill") && + command.contains("--color=#4C8DFF") && + command.contains("--priority=100") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to publish high-priority Codex input status, saw \(state.snapshot())" + ) + XCTAssertTrue(process.isRunning, "Monitor should keep watching the turn after publishing input notification") + } + + func testCodexHookMonitorNotifiesOnResponseItemRequestUserInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex-response-item") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-monitor-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-session-monitor-response-item" + let turnId = "turn-monitor-response-item" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let transcriptURL = root.appendingPathComponent("rollout-\(sessionId).jsonl") + try """ + {"timestamp":"2026-04-25T07:55:29.462Z","type":"session_meta","payload":{"id":"\(sessionId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.500Z","type":"turn_context","payload":{"turn_id":"\(turnId)","cwd":"\(root.path)"}} + {"timestamp":"2026-04-25T07:55:29.700Z","type":"response_item","payload":{"type":"function_call","name":"request_user_input","arguments":"{\\"questions\\":[{\\"id\\":\\"demo_type\\",\\"header\\":\\"Demo Type\\",\\"question\\":\\"What kind of demo plan should I create?\\",\\"options\\":[{\\"label\\":\\"Product walkthrough (Recommended)\\",\\"description\\":\\"A timed agenda.\\"}]}]}","call_id":"call-plan-function"}} + """.write(to: transcriptURL, atomically: true, encoding: .utf8) + + _ = startMockServerSignal(listenerFD: listenerFD, state: state) { line in + if let data = line.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let id = payload["id"] as? String { + return self.v2Response(id: id, ok: true, result: ["surfaces": [["id": surfaceId, "ref": surfaceId]]]) + } + return "OK" + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_AGENT_HOOK_STATE_DIR"] = root.path + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = [ + "hooks", "codex", "monitor", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--session", + sessionId, + "--turn", + turnId, + "--transcript", + transcriptURL.path, + ] + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + try process.run() + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + defer { + if process.isRunning { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + _ = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + _ = stderrPipe.fileHandleForReading.readDataToEndOfFile() + } + + XCTAssertTrue( + waitForProcess(process, toHoldOpenFile: transcriptURL.path, timeout: 2), + "Monitor did not start watching the response_item request_user_input transcript" + ) + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.contains("notify_target \(workspaceId) \(surfaceId) Codex|Waiting|What kind of demo plan should I create?") + }, + "Expected monitor to send Codex input notification from response_item, saw \(state.snapshot())" + ) + XCTAssertTrue( + waitForSocketCommand(state: state, timeout: 5) { command in + command.contains("set_status codex Codex needs input") && + command.contains("--icon=bell.fill") && + command.contains("--color=#4C8DFF") && + command.contains("--priority=100") && + command.contains("--tab=\(workspaceId)") + }, + "Expected monitor to publish high-priority Codex input status, saw \(state.snapshot())" + ) + XCTAssertTrue(process.isRunning, "Monitor should keep watching the turn after publishing input notification") + } + func testCodexHookMonitorReResolvesUnavailableTranscriptPath() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index bfca2edf6f47..43aeaaaad14a 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import hashlib import os import shutil import socket @@ -18,6 +19,41 @@ from claude_teams_test_utils import resolve_cmux_cli +CODEX_HOOK_EVENT_LABELS = { + "PreToolUse": "pre_tool_use", + "PermissionRequest": "permission_request", + "PostToolUse": "post_tool_use", + "PreCompact": "pre_compact", + "PostCompact": "post_compact", + "SessionStart": "session_start", + "UserPromptSubmit": "user_prompt_submit", + "Stop": "stop", +} + +CODEX_HOOK_EVENTS_WITH_MATCHERS = { + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", +} + +CMUX_CODEX_HOOK_SUBCOMMANDS = ( + "session-start", + "prompt-submit", + "stop", +) + +CMUX_CODEX_FEED_EVENTS = ( + "PreToolUse", + "PermissionRequest", +) + +FAKE_WORKSPACE_ID = "11111111-1111-1111-1111-111111111111" +FAKE_SURFACE_ID = "22222222-2222-2222-2222-222222222222" + + class FakeCmuxSocket: def __init__( self, @@ -28,7 +64,7 @@ def __init__( ): self.path = path self.decision = decision - self.surfaces = surfaces if surfaces is not None else [{"id": "surface-codex-feed-test"}] + self.surfaces = surfaces if surfaces is not None else [{"id": FAKE_SURFACE_ID}] self.drop_first_surface_list = drop_first_surface_list self._dropped_surface_list = False self.frames: list[dict] = [] @@ -162,8 +198,8 @@ def test_codex_stop_reaps_transcript_monitor(cli_path: str, root: Path) -> None: turn_id = f"codex-monitor-reap-turn-{os.getpid()}" env = os.environ.copy() env["CMUX_SOCKET_PATH"] = str(socket_path) - env["CMUX_SURFACE_ID"] = "surface-codex-feed-test" - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_SURFACE_ID"] = FAKE_SURFACE_ID + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID env["CMUX_AGENT_HOOK_STATE_DIR"] = str(state_dir) with FakeCmuxSocket(socket_path, None): @@ -226,8 +262,8 @@ def test_codex_stop_without_turn_keeps_session_wide_monitor(cli_path: str, root: session_id = f"codex-monitor-session-wide-session-{os.getpid()}" env = os.environ.copy() env["CMUX_SOCKET_PATH"] = str(socket_path) - env["CMUX_SURFACE_ID"] = "surface-codex-feed-test" - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_SURFACE_ID"] = FAKE_SURFACE_ID + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID env["CMUX_AGENT_HOOK_STATE_DIR"] = str(state_dir) with FakeCmuxSocket(socket_path, None): @@ -305,8 +341,8 @@ def test_codex_prompt_submit_starts_monitor_when_lease_write_fails(cli_path: str turn_id = f"codex-monitor-lease-failure-turn-{os.getpid()}" env = os.environ.copy() env["CMUX_SOCKET_PATH"] = str(socket_path) - env["CMUX_SURFACE_ID"] = "surface-codex-feed-test" - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_SURFACE_ID"] = FAKE_SURFACE_ID + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID env["CMUX_AGENT_HOOK_STATE_DIR"] = str(bad_state_dir) with FakeCmuxSocket(socket_path, None): @@ -347,7 +383,7 @@ def test_codex_monitor_exits_when_workspace_has_no_surfaces(cli_path: str, root: session_id = f"codex-monitor-empty-surfaces-session-{os.getpid()}" env = os.environ.copy() env["CMUX_SOCKET_PATH"] = str(socket_path) - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID env["CMUX_AGENT_HOOK_STATE_DIR"] = str(state_dir) with FakeCmuxSocket(socket_path, None, surfaces=[]) as fake: @@ -361,7 +397,7 @@ def test_codex_monitor_exits_when_workspace_has_no_surfaces(cli_path: str, root: "codex", "monitor", "--workspace", - "workspace-codex-feed-test", + FAKE_WORKSPACE_ID, "--session", session_id, "--transcript", @@ -401,7 +437,7 @@ def test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path: str, root: session_id = f"codex-monitor-timeout-session-{os.getpid()}" env = os.environ.copy() env["CMUX_SOCKET_PATH"] = str(socket_path) - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID with FakeCmuxSocket(socket_path, None, drop_first_surface_list=True) as fake: result = subprocess.run( @@ -413,7 +449,7 @@ def test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path: str, root: "codex", "monitor", "--workspace", - "workspace-codex-feed-test", + FAKE_WORKSPACE_ID, "--session", session_id, "--turn", @@ -441,8 +477,8 @@ def test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path: str, root: def run_feed_hook(cli_path: str, socket_path: Path, payload: dict, decision: dict | None) -> tuple[dict, dict]: env = os.environ.copy() - env["CMUX_SURFACE_ID"] = "surface-codex-feed-test" - env["CMUX_WORKSPACE_ID"] = "workspace-codex-feed-test" + env["CMUX_SURFACE_ID"] = FAKE_SURFACE_ID + env["CMUX_WORKSPACE_ID"] = FAKE_WORKSPACE_ID with FakeCmuxSocket(socket_path, decision) as fake: result = subprocess.run( [ @@ -492,6 +528,156 @@ def assert_codex_allow_has_no_persistent_fields(stdout: dict) -> None: raise AssertionError(f"Codex permission output included unsupported fields {present}: {stdout!r}") +def codex_command_hook_hash( + *, + event_label: str, + matcher: str | None, + command: str, + timeout: int, + status_message: str | None, +) -> str: + handler: dict = { + "async": False, + "command": command, + "timeout": max(timeout, 1), + "type": "command", + } + if status_message is not None: + handler["statusMessage"] = status_message + identity: dict = { + "event_name": event_label, + "hooks": [handler], + } + if matcher is not None: + identity["matcher"] = matcher + canonical = json.dumps(identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + return f"sha256:{hashlib.sha256(canonical).hexdigest()}" + + +def cmux_codex_hook_command(subcommand: str) -> str: + return ( + '[ -n "$CMUX_SURFACE_ID" ] && [ "$CMUX_CODEX_HOOKS_DISABLED" != "1" ] ' + f"&& command -v cmux >/dev/null 2>&1 && cmux hooks codex {subcommand} || echo '{{}}'" + ) + + +def cmux_codex_feed_command(agent_event: str) -> str: + return ( + '[ -n "$CMUX_SURFACE_ID" ] && [ "$CMUX_CODEX_HOOKS_DISABLED" != "1" ] ' + f"&& command -v cmux >/dev/null 2>&1 && cmux hooks feed --source codex --event {agent_event} " + "|| echo '{}'" + ) + + +def is_cmux_codex_hook_command(command: str) -> bool: + hook_commands = {cmux_codex_hook_command(subcommand) for subcommand in CMUX_CODEX_HOOK_SUBCOMMANDS} + feed_commands = {cmux_codex_feed_command(agent_event) for agent_event in CMUX_CODEX_FEED_EVENTS} + return command in hook_commands or command in feed_commands + + +def toml_basic_string_unescape(value: str) -> str: + escaped = { + "b": "\b", + "t": "\t", + "n": "\n", + "f": "\f", + "r": "\r", + '"': '"', + "\\": "\\", + } + result: list[str] = [] + index = 0 + while index < len(value): + char = value[index] + if char != "\\": + result.append(char) + index += 1 + continue + + index += 1 + if index >= len(value): + raise AssertionError(f"trailing TOML escape in {value!r}") + escape = value[index] + if escape in escaped: + result.append(escaped[escape]) + index += 1 + elif escape in {"u", "U"}: + width = 4 if escape == "u" else 8 + start = index + 1 + end = start + width + hex_value = value[start:end] + if len(hex_value) != width: + raise AssertionError(f"short TOML unicode escape in {value!r}") + result.append(chr(int(hex_value, 16))) + index = end + else: + raise AssertionError(f"unsupported TOML escape \\{escape} in {value!r}") + return "".join(result) + + +def codex_hook_trust_state(config_toml: str) -> dict[str, dict[str, str]]: + state: dict[str, dict[str, str]] = {} + current_key: str | None = None + prefix = '[hooks.state."' + suffix = '"]' + + for line in config_toml.splitlines(): + stripped = line.strip() + if stripped.startswith(prefix) and stripped.endswith(suffix): + current_key = toml_basic_string_unescape(stripped[len(prefix) : -len(suffix)]) + state[current_key] = {} + continue + if stripped.startswith("["): + current_key = None + continue + if current_key is None: + continue + key, separator, raw_value = stripped.partition("=") + if separator != "=" or key.strip() != "trusted_hash": + continue + value = raw_value.strip() + if not value.startswith('"') or not value.endswith('"'): + raise AssertionError(f"trusted_hash is not a TOML basic string: {line!r}") + state[current_key]["trusted_hash"] = toml_basic_string_unescape(value[1:-1]) + + return state + + +def expected_cmux_codex_hook_trust(hooks: dict, hooks_path: Path) -> dict[str, str]: + expected: dict[str, str] = {} + hooks_path = hooks_path.resolve() + for event_name, groups in hooks.get("hooks", {}).items(): + event_label = CODEX_HOOK_EVENT_LABELS.get(event_name) + if event_label is None: + continue + for group_index, group in enumerate(groups): + matcher = group.get("matcher") if event_name in CODEX_HOOK_EVENTS_WITH_MATCHERS else None + for handler_index, hook in enumerate(group.get("hooks", [])): + command = hook.get("command", "") + if not is_cmux_codex_hook_command(command): + continue + key = f"{hooks_path}:{event_label}:{group_index}:{handler_index}" + expected[key] = codex_command_hook_hash( + event_label=event_label, + matcher=matcher, + command=command, + timeout=int(hook.get("timeout", 600)), + status_message=hook.get("statusMessage"), + ) + return expected + + +def codex_hook_commands(hooks: dict) -> list[str]: + commands: list[str] = [] + for groups in hooks.get("hooks", {}).values(): + for group in groups: + for hook in group.get("hooks", []): + command = hook.get("command") + if isinstance(command, str): + commands.append(command) + return commands + + def test_install_adds_codex_permission_request_hook(cli_path: str, root: Path) -> None: codex_home = root / "codex-home" codex_home.mkdir() @@ -524,12 +710,358 @@ def test_install_adds_codex_permission_request_hook(cli_path: str, root: Path) - raise AssertionError(f"wrong {event_name} timeout: {groups[-1]!r}") config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") - if "codex_hooks = true" not in config_toml: - raise AssertionError(f"codex_hooks feature was not enabled: {config_toml!r}") + if "hooks = true" not in config_toml: + raise AssertionError(f"hooks feature was not enabled: {config_toml!r}") + if "codex_hooks" in config_toml: + raise AssertionError(f"deprecated codex_hooks feature was written: {config_toml!r}") + state = codex_hook_trust_state(config_toml) + expected_trust = expected_cmux_codex_hook_trust(hooks, codex_home / "hooks.json") + if not expected_trust: + raise AssertionError(f"expected cmux Codex trust entries, got {expected_trust!r}") + for key, trusted_hash in expected_trust.items(): + if state.get(key, {}).get("trusted_hash") != trusted_hash: + raise AssertionError( + f"missing trusted hash for {key}: expected {trusted_hash!r}, got state {state!r}" + ) + + +def test_install_escapes_codex_hook_trust_state_keys(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home\twith\ncontrols" + codex_home.mkdir() + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + state = codex_hook_trust_state(config_toml) + expected_trust = expected_cmux_codex_hook_trust(hooks, codex_home / "hooks.json") + if not expected_trust: + raise AssertionError(f"expected cmux Codex trust entries, got {expected_trust!r}") + for key, trusted_hash in expected_trust.items(): + if state.get(key, {}).get("trusted_hash") != trusted_hash: + raise AssertionError( + f"missing escaped-key trusted hash for {key}: expected {trusted_hash!r}, got state {state!r}" + ) + + +def test_install_preserves_codex_hook_position_with_third_party_hooks(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-third-party" + codex_home.mkdir() + cmux_pre_tool = ( + '[ -n "$CMUX_SURFACE_ID" ] && [ "$CMUX_CODEX_HOOKS_DISABLED" != "1" ] ' + "&& command -v cmux >/dev/null 2>&1 && cmux hooks feed --source codex --event PreToolUse || echo '{}'" + ) + orca_hook = ( + "if [ -x '/Users/lawrence/Library/Application Support/orca/agent-hooks/codex-hook.sh' ]; " + "then /bin/sh '/Users/lawrence/Library/Application Support/orca/agent-hooks/codex-hook.sh'; fi" + ) + (codex_home / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"hooks": [{"type": "command", "command": cmux_pre_tool, "timeout": 120000}]}, + {"hooks": [{"type": "command", "command": orca_hook}]}, + ] + } + }, + indent=2, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + groups = hooks["hooks"]["PreToolUse"] + first_command = groups[0]["hooks"][0]["command"] + second_command = groups[1]["hooks"][0]["command"] + if "cmux hooks feed --source codex --event PreToolUse" not in first_command: + raise AssertionError(f"cmux hook did not keep its existing position: {groups!r}") + if second_command != orca_hook: + raise AssertionError(f"third-party hook was not preserved after cmux hook: {groups!r}") + + +def test_install_preserves_each_codex_hook_position_with_interleaved_third_party_hooks( + cli_path: str, root: Path +) -> None: + codex_home = root / "codex-home-interleaved" + codex_home.mkdir() + cmux_pre_tool = cmux_codex_feed_command("PreToolUse") + user_hook_before = "printf before" + user_hook_middle = "printf middle" + user_hook_after = "printf after" + (codex_home / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"hooks": [{"type": "command", "command": user_hook_before}]}, + {"hooks": [{"type": "command", "command": cmux_pre_tool, "timeout": 120000}]}, + {"hooks": [{"type": "command", "command": user_hook_middle}]}, + {"hooks": [{"type": "command", "command": cmux_pre_tool, "timeout": 120000}]}, + {"hooks": [{"type": "command", "command": user_hook_after}]}, + ] + } + }, + indent=2, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + commands = [group["hooks"][0]["command"] for group in hooks["hooks"]["PreToolUse"]] + expected = [ + user_hook_before, + cmux_pre_tool, + user_hook_middle, + cmux_pre_tool, + user_hook_after, + ] + if commands != expected: + raise AssertionError(f"interleaved cmux hook positions changed: {commands!r}") + + +def test_install_collapses_consecutive_codex_hook_positions(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-consecutive" + codex_home.mkdir() + cmux_pre_tool = cmux_codex_feed_command("PreToolUse") + user_hook_before = "printf before" + user_hook_after = "printf after" + (codex_home / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"hooks": [{"type": "command", "command": user_hook_before}]}, + {"hooks": [{"type": "command", "command": cmux_pre_tool, "timeout": 120000}]}, + {"hooks": [{"type": "command", "command": cmux_pre_tool, "timeout": 120000}]}, + {"hooks": [{"type": "command", "command": user_hook_after}]}, + ] + } + }, + indent=2, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + commands = [group["hooks"][0]["command"] for group in hooks["hooks"]["PreToolUse"]] + expected = [ + user_hook_before, + cmux_pre_tool, + user_hook_after, + ] + if commands != expected: + raise AssertionError(f"consecutive cmux hooks were not collapsed: {commands!r}") + + +def test_install_replaces_legacy_codex_hook_commands(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-legacy-hooks" + codex_home.mkdir() + (codex_home / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "cmux codex-hook stop"}]}, + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "cmux feed-hook --source codex --event PreToolUse", + } + ] + }, + ], + } + }, + indent=2, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + commands = codex_hook_commands(hooks) + if any("cmux codex-hook" in command or "cmux feed-hook --source" in command for command in commands): + raise AssertionError(f"legacy cmux hook commands were not removed: {commands!r}") + if cmux_codex_hook_command("stop") not in commands: + raise AssertionError(f"current Stop hook was not installed: {commands!r}") + if cmux_codex_feed_command("PreToolUse") not in commands: + raise AssertionError(f"current PreToolUse feed hook was not installed: {commands!r}") + + +def test_install_migrates_legacy_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-legacy" + codex_home.mkdir() + # Real configs can contain both names after users tried the old and new flags. + (codex_home / "config.toml").write_text( + "[features]\napps = true\ncodex_hooks = false\nhooks = false\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "codex_hooks" in config_toml: + raise AssertionError(f"deprecated codex_hooks feature was preserved: {config_toml!r}") + if "hooks = true" not in config_toml: + raise AssertionError(f"hooks feature was not enabled: {config_toml!r}") + if "apps = true" not in config_toml: + raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") + + +def test_install_migrates_dotted_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-dotted-legacy" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "features.apps = true\nfeatures.codex_hooks = false\nfeatures.hooks = false\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "features.codex_hooks" in config_toml or "[features]" in config_toml: + raise AssertionError(f"dotted legacy config was rewritten incorrectly: {config_toml!r}") + if "features.hooks = true" not in config_toml: + raise AssertionError(f"dotted hooks feature was not enabled: {config_toml!r}") + if "features.apps = true" not in config_toml: + raise AssertionError(f"existing dotted feature setting was not preserved: {config_toml!r}") + + +def test_uninstall_preserves_existing_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-uninstall-existing" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "[features]\napps = true\nhooks = true\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + for action in ["install", "uninstall"]: + result = subprocess.run( + [cli_path, "hooks", "codex", action, "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "hooks = true" not in config_toml: + raise AssertionError(f"pre-existing hooks feature was removed: {config_toml!r}") + if "apps = true" not in config_toml: + raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") def test_install_codex_hooks_only_edits_real_features_table(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home" + codex_home = root / "codex-home-real-features" codex_home.mkdir() config_path = codex_home / "config.toml" config_path.write_text( @@ -562,8 +1094,10 @@ def test_install_codex_hooks_only_edits_real_features_table(cli_path: str, root: ) config_toml = config_path.read_text(encoding="utf-8") - if config_toml.count("codex_hooks = true") != 1: - raise AssertionError(f"codex_hooks should be inserted exactly once: {config_toml!r}") + if config_toml.count("hooks = true") != 1: + raise AssertionError(f"hooks should be inserted exactly once: {config_toml!r}") + if "codex_hooks" in config_toml: + raise AssertionError(f"deprecated codex_hooks feature was written: {config_toml!r}") if "# See [features] in the documentation." not in config_toml: raise AssertionError(f"comment with [features] was corrupted: {config_toml!r}") if 'note = "literal [features] mention"' not in config_toml: @@ -571,8 +1105,10 @@ def test_install_codex_hooks_only_edits_real_features_table(cli_path: str, root: lines = config_toml.splitlines() features_index = lines.index("[features]") - if lines[features_index + 1] != "codex_hooks = true": - raise AssertionError(f"codex_hooks should be inserted into [features]: {config_toml!r}") + if lines[features_index + 1] != "# cmux-codex-hooks-feature-78f1e4ba-66df-4d35-93c1-67fdf1cbb7df begin": + raise AssertionError(f"cmux marker should be inserted into [features]: {config_toml!r}") + if lines[features_index + 2] != "hooks = true": + raise AssertionError(f"hooks should be inserted into [features]: {config_toml!r}") def test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_path: str, root: Path) -> None: @@ -598,8 +1134,10 @@ def test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_pat ) installed_config = config_path.read_text(encoding="utf-8") - if "[features]" not in installed_config or "codex_hooks = true" not in installed_config: - raise AssertionError(f"install should add the codex_hooks feature table: {installed_config!r}") + if "[features]" not in installed_config or "hooks = true" not in installed_config: + raise AssertionError(f"install should add the hooks feature table: {installed_config!r}") + if "codex_hooks" in installed_config: + raise AssertionError(f"install should not add deprecated codex_hooks: {installed_config!r}") result = subprocess.run( [cli_path, "hooks", "codex", "uninstall", "--yes"], @@ -618,6 +1156,222 @@ def test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_pat if config_toml != original_config: raise AssertionError(f"uninstall should remove the empty [features] table: {config_toml!r}") +def test_uninstall_restores_disabled_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-uninstall-disabled" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "[features]\napps = true\nhooks = false\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + for action in ["install", "uninstall"]: + result = subprocess.run( + [cli_path, "hooks", "codex", action, "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "hooks = false" not in config_toml: + raise AssertionError(f"pre-existing disabled hooks feature was not restored: {config_toml!r}") + if "hooks = true" in config_toml: + raise AssertionError(f"cmux-owned hooks feature was not removed: {config_toml!r}") + if "apps = true" not in config_toml: + raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") + + +def test_uninstall_restores_disabled_dotted_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-uninstall-dotted-disabled" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "features.apps = true\nfeatures.hooks = false\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + for action in ["install", "uninstall"]: + result = subprocess.run( + [cli_path, "hooks", "codex", action, "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "features.hooks = false" not in config_toml: + raise AssertionError(f"pre-existing disabled dotted hooks feature was not restored: {config_toml!r}") + if "features.hooks = true" in config_toml: + raise AssertionError(f"cmux-owned dotted hooks feature was not removed: {config_toml!r}") + if "features.apps = true" not in config_toml: + raise AssertionError(f"existing dotted feature setting was not preserved: {config_toml!r}") + + +def test_install_scans_features_past_bracketed_array(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-bracketed-array" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "[features]\napps = [\n [1, 2],\n]\nhooks = false\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + for action in ["install", "uninstall"]: + result = subprocess.run( + [cli_path, "hooks", "codex", action, "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if action == "install" and config_toml.count("hooks = true") != 1: + raise AssertionError(f"install wrote duplicate hooks settings: {config_toml!r}") + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "hooks = false" not in config_toml or "hooks = true" in config_toml: + raise AssertionError(f"uninstall did not restore hooks after bracketed array: {config_toml!r}") + if "[1, 2]" not in config_toml: + raise AssertionError(f"bracketed array content was not preserved: {config_toml!r}") + + +def test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-uninstall-owned" + codex_home.mkdir() + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + for action in ["install", "uninstall"]: + result = subprocess.run( + [cli_path, "hooks", "codex", action, "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex {action} failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "hooks = true" in config_toml or "codex_hooks" in config_toml: + raise AssertionError(f"cmux-owned hooks feature was not removed: {config_toml!r}") + if "hooks.state" in config_toml or "trusted_hash" in config_toml: + raise AssertionError(f"cmux-owned hook trust was not removed: {config_toml!r}") + if "[features]" in config_toml: + raise AssertionError(f"empty features table was preserved: {config_toml!r}") + + +def test_uninstall_preserves_unowned_hook_trust_when_cmux_marker_is_unclosed( + cli_path: str, root: Path +) -> None: + codex_home = root / "codex-home-unclosed-trust" + codex_home.mkdir() + (codex_home / "hooks.json").write_text('{"hooks": {}}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "[features]\n" + "hooks = true\n" + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin\n" + "[hooks.state.\"/tmp/cmux/hooks.json:pre_tool_use:0:0\"]\n" + 'trusted_hash = "sha256:cmux"\n' + "[hooks.state.\"/tmp/third-party/hooks.json:pre_tool_use:0:0\"]\n" + 'trusted_hash = "sha256:third-party"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin" in config_toml: + raise AssertionError(f"orphaned cmux hook trust marker was preserved: {config_toml!r}") + if 'trusted_hash = "sha256:third-party"' not in config_toml: + raise AssertionError(f"unowned hook trust was removed: {config_toml!r}") + + +def test_install_recovers_hook_trust_when_cmux_marker_is_unclosed( + cli_path: str, root: Path +) -> None: + codex_home = root / "codex-home-unclosed-trust-install" + codex_home.mkdir() + stale_key = f"{(codex_home / 'hooks.json').resolve()}:pre_tool_use:0:0" + (codex_home / "config.toml").write_text( + "[features]\n" + "hooks = true\n" + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin\n" + f'[hooks.state."{stale_key}"]\n' + 'trusted_hash = "sha256:stale"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = (codex_home / "config.toml").read_text(encoding="utf-8") + if "approved cmux hooks" not in result.stdout: + raise AssertionError(f"install did not report recovered hook trust approval: {result.stdout!r}") + if config_toml.count("# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin") != 1: + raise AssertionError(f"install did not write one fresh cmux hook trust marker: {config_toml!r}") + if config_toml.count("# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end") != 1: + raise AssertionError(f"install did not close the recovered hook trust block: {config_toml!r}") + if 'trusted_hash = "sha256:stale"' in config_toml: + raise AssertionError(f"install preserved stale cmux hook trust: {config_toml!r}") + hooks = json.loads((codex_home / "hooks.json").read_text(encoding="utf-8")) + state = codex_hook_trust_state(config_toml) + expected_trust = expected_cmux_codex_hook_trust(hooks, codex_home / "hooks.json") + for key, trusted_hash in expected_trust.items(): + if state.get(key, {}).get("trusted_hash") != trusted_hash: + raise AssertionError( + f"missing recovered trusted hash for {key}: expected {trusted_hash!r}, got state {state!r}" + ) + def test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path: str, root: Path) -> None: codex_home = root / "codex-home-legacy-uninstall" @@ -673,8 +1427,69 @@ def test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path: str, root: raise AssertionError(f"existing feature setting was not preserved: {config_toml!r}") +def test_install_surfaces_invalid_codex_config_encoding(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-invalid-install-config" + codex_home.mkdir() + config_path = codex_home / "config.toml" + invalid_bytes = b"\xff" + config_path.write_bytes(invalid_bytes) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode == 0: + raise AssertionError("hooks codex install unexpectedly succeeded with invalid config encoding") + if config_path.read_bytes() != invalid_bytes: + raise AssertionError("hooks codex install overwrote unreadable config content") + + +def test_uninstall_surfaces_invalid_codex_config_encoding(cli_path: str, root: Path) -> None: + codex_home = root / "codex-home-invalid-uninstall-config" + codex_home.mkdir() + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + install_result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if install_result.returncode != 0: + raise AssertionError( + "initial hooks codex install failed " + f"exit={install_result.returncode}\nstdout={install_result.stdout}\nstderr={install_result.stderr}" + ) + + config_path = codex_home / "config.toml" + invalid_bytes = b"\xff" + config_path.write_bytes(invalid_bytes) + + result = subprocess.run( + [cli_path, "hooks", "codex", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode == 0: + raise AssertionError("hooks codex uninstall unexpectedly succeeded with invalid config encoding") + if config_path.read_bytes() != invalid_bytes: + raise AssertionError("hooks codex uninstall overwrote unreadable config content") + + def test_install_codex_hooks_preserves_config_when_toml_read_fails(cli_path: str, root: Path) -> None: - codex_home = root / "codex-home" + codex_home = root / "codex-home-toml-read-fails" codex_home.mkdir() config_path = codex_home / "config.toml" original_bytes = b'model = "safe"\ninvalid_utf8 = "\xff"\n' @@ -798,9 +1613,25 @@ def main() -> int: test_codex_monitor_exits_when_workspace_has_no_surfaces(cli_path, root) test_codex_monitor_survives_transient_owner_rpc_timeout(cli_path, root) test_install_adds_codex_permission_request_hook(cli_path, root) + test_install_escapes_codex_hook_trust_state_keys(cli_path, root) + test_install_preserves_codex_hook_position_with_third_party_hooks(cli_path, root) + test_install_preserves_each_codex_hook_position_with_interleaved_third_party_hooks(cli_path, root) + test_install_collapses_consecutive_codex_hook_positions(cli_path, root) + test_install_replaces_legacy_codex_hook_commands(cli_path, root) + test_install_migrates_legacy_codex_hooks_feature(cli_path, root) + test_install_migrates_dotted_codex_hooks_feature(cli_path, root) + test_uninstall_preserves_existing_codex_hooks_feature(cli_path, root) test_install_codex_hooks_only_edits_real_features_table(cli_path, root) test_uninstall_codex_hooks_removes_empty_features_table_from_install(cli_path, root) + test_uninstall_restores_disabled_codex_hooks_feature(cli_path, root) + test_uninstall_restores_disabled_dotted_codex_hooks_feature(cli_path, root) + test_install_scans_features_past_bracketed_array(cli_path, root) + test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path, root) + test_uninstall_preserves_unowned_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) + test_install_recovers_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path, root) + 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)