diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index 227e8cbb0..6e6b23109 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -5171,6 +5171,14 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { await handleCodexNativeEvent(event, session: session) } + @_spi(TestSupport) + public func test_settleCodexComputerUseActivationAfterTurn( + _ session: AgentModeViewModel.TabSession, + reason: String = "test" + ) { + settleCodexComputerUseActivationAfterTurn(session, reason: reason) + } + @_spi(TestSupport) public static func test_mergeCommandRunningUpdates( existing: CodexNativeSessionController.CommandExecutionRunningUpdate, diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexComputerUseWorkflow.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexComputerUseWorkflow.swift index 9a41807f0..2e02c649a 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexComputerUseWorkflow.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexComputerUseWorkflow.swift @@ -2,6 +2,7 @@ import Foundation extension Notification.Name { static let codexGoalSupportDidChange = Notification.Name("RepoPrompt.codexGoalSupportDidChange") + static let codexComputerUseAvailabilityDidChange = Notification.Name("RepoPrompt.codexComputerUseAvailabilityDidChange") } private enum CodexNativeFeatureGate: Hashable { @@ -129,16 +130,75 @@ enum CodexGoalSupport { enum CodexComputerUseWorkflow { static let commandName = "computer-use" - static let disabledMessage = "Codex computer-use is currently disabled in RepoPrompt because it requires additional computer permissions/accessibility setup." + @MainActor + static var disabledMessage: String { + availability.primaryUnavailableMessage + } + + @MainActor + static var availability: CodexComputerUseAvailability { + CodexComputerUseAvailability(status: currentStatus(includeTimestamp: false)) + } + + @MainActor static var isEnabled: Bool { - CodexNativeFeatureGate.computerUse.isEnabled(persistedValue: false) + availability.isReady + } + + @MainActor + static func currentStatus(includeTimestamp: Bool = true) -> CodexComputerUseStatus { + currentStatus( + persistedOptIn: GlobalSettingsStore.shared.codexComputerUseEnabled(), + includeTimestamp: includeTimestamp + ) + } + + static func currentStatus( + persistedOptIn: Bool, + statusService: CodexComputerUseStatusService = .shared, + includeTimestamp: Bool = true + ) -> CodexComputerUseStatus { + let effectiveOptIn = CodexNativeFeatureGate.computerUse.isEnabled(persistedValue: persistedOptIn) + return statusService.currentStatus( + optInEnabled: effectiveOptIn, + includeTimestamp: includeTimestamp + ) + } + + static func resolvedAvailability( + persistedOptIn: Bool, + prerequisites: CodexComputerUsePrerequisiteSnapshot + ) -> CodexComputerUseAvailability { + let featureOptIn = CodexNativeFeatureGate.computerUse.isEnabled(persistedValue: persistedOptIn) + return CodexComputerUseAvailability( + featureOptIn: featureOptIn, + prerequisites: prerequisites + ) + } + + @MainActor + static func setEnabled(_ enabled: Bool) { + GlobalSettingsStore.shared.setCodexComputerUseEnabled(enabled) + } + + static func postAvailabilityDidChangeIfNeeded(previousValue: Bool, currentValue: Bool) { + guard currentValue != previousValue else { return } + NotificationCenter.default.post(name: .codexComputerUseAvailabilityDidChange, object: nil) + } + + static func prerequisiteSnapshot() -> CodexComputerUsePrerequisiteSnapshot { + CodexComputerUseStatusService.shared.prerequisiteSnapshot() } #if DEBUG static func setEnabledForTesting(_ value: Bool?) { CodexNativeFeatureGate.computerUse.setEnabledForTesting(value) } + + static func setPrerequisiteSnapshotForTesting(_ snapshot: CodexComputerUsePrerequisiteSnapshot?) { + CodexComputerUseStatusService.setPrerequisiteSnapshotForTesting(snapshot) + } #endif static func bubbleWorkflowDefinition() -> AgentWorkflowDefinition { @@ -167,9 +227,10 @@ enum CodexComputerUseWorkflow { Safety requirements: - Clarify missing target app/site/account, destination, credentials, or intended action before operating. - - Ask for explicit confirmation before destructive, purchasing, sending, publishing, account-changing, or otherwise externally visible actions. + - Treat macOS Screen Recording/Accessibility as OS prerequisites only; still ask for app access when Codex prompts for an allowed target app. - Do not treat RepoPrompt MCP auto-approval as approval for non-RepoPrompt MCP, plugin, browser, or computer-use tools. - - Keep RepoPrompt workspace file edits separate from external UI automation; explain which surface you are acting on. + - Keep RepoPrompt workspace file edits and shell commands under the session's sandbox/approval policy; desktop app actions are a separate surface. + - Ask for explicit confirmation before destructive, purchasing, sending, publishing, account-changing, or otherwise externally visible actions. - Prefer non-destructive inspection and reporting before taking action. diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index 9fbe9e26d..911127740 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -1406,7 +1406,7 @@ struct AgentComposerView: View, Equatable { steeringUnsupportedMessage = message } steeringUnsupportedDismissTask = Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) + try? await Task.sleep(nanoseconds: 10_000_000_000) guard !Task.isCancelled else { return } await MainActor.run { withAnimation(.easeInOut(duration: 0.2)) { diff --git a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift index b1643a487..7d253cb71 100644 --- a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift +++ b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift @@ -314,6 +314,7 @@ struct GlobalScalarPreferences: Codable, Equatable { var maxBackgroundAgentComposeTabs: Int? var showBuiltInWorkflowCleanupGuidance: Bool? var codexGoalSupportEnabled: Bool? + var codexComputerUseEnabled: Bool? var restrictMCPAgentDiscoveryToRoleLabels: Bool? init( @@ -325,6 +326,7 @@ struct GlobalScalarPreferences: Codable, Equatable { maxBackgroundAgentComposeTabs: Int? = nil, showBuiltInWorkflowCleanupGuidance: Bool? = nil, codexGoalSupportEnabled: Bool? = nil, + codexComputerUseEnabled: Bool? = nil, restrictMCPAgentDiscoveryToRoleLabels: Bool? = nil ) { self.proEditAgentMode = proEditAgentMode @@ -335,6 +337,7 @@ struct GlobalScalarPreferences: Codable, Equatable { self.maxBackgroundAgentComposeTabs = maxBackgroundAgentComposeTabs self.showBuiltInWorkflowCleanupGuidance = showBuiltInWorkflowCleanupGuidance self.codexGoalSupportEnabled = codexGoalSupportEnabled + self.codexComputerUseEnabled = codexComputerUseEnabled self.restrictMCPAgentDiscoveryToRoleLabels = restrictMCPAgentDiscoveryToRoleLabels } } diff --git a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift index 5f11ebd30..40b55a04d 100644 --- a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift +++ b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift @@ -913,6 +913,60 @@ class GlobalSettingsStore: ObservableObject { CodexGoalSupport.postDidChangeIfNeeded(previousValue: oldValue, currentValue: codexGoalSupportEnabled()) } + func codexComputerUseEnabled() -> Bool { + scalarPreferences.agentMode?.codexComputerUseEnabled ?? false + } + + func setCodexComputerUseEnabled(_ enabled: Bool, commit: Bool = true) { + let previousAvailability = CodexComputerUseWorkflow.resolvedAvailability( + persistedOptIn: codexComputerUseEnabled(), + prerequisites: CodexComputerUseWorkflow.prerequisiteSnapshot() + ).isReady + updateAgentModeScalar(commit: commit) { settings in + settings.codexComputerUseEnabled = enabled + } + let currentAvailability = CodexComputerUseWorkflow.resolvedAvailability( + persistedOptIn: codexComputerUseEnabled(), + prerequisites: CodexComputerUseWorkflow.prerequisiteSnapshot() + ).isReady + CodexComputerUseWorkflow.postAvailabilityDidChangeIfNeeded( + previousValue: previousAvailability, + currentValue: currentAvailability + ) + } + + func codexRawEventLoggingEnabled() -> Bool { + CodexAppServerDiagnostics.rawEventLoggingEnabled(defaults: defaults) + } + + func setCodexRawEventLoggingEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: CodexAppServerDiagnostics.rawEventLoggingEnabledKey) + } + + func codexRawEventLogFilePath() -> String { + CodexAppServerDiagnostics.rawEventLogFilePath(defaults: defaults) + } + + func setCodexRawEventLogFilePath(_ path: String) { + CodexAppServerDiagnostics.setRawEventLogFilePath(path, defaults: defaults) + } + + func codexAppServerDiagnosticsEnabled() -> Bool { + CodexAppServerDiagnostics.appServerDiagnosticsEnabled(defaults: defaults) + } + + func setCodexAppServerDiagnosticsEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: CodexAppServerDiagnostics.appServerDiagnosticsEnabledKey) + } + + func codexAppServerDiagnosticsLogFilePath() -> String { + CodexAppServerDiagnostics.appServerDiagnosticsLogFilePath(defaults: defaults) + } + + func setCodexAppServerDiagnosticsLogFilePath(_ path: String) { + CodexAppServerDiagnostics.setAppServerDiagnosticsLogFilePath(path, defaults: defaults) + } + #if DEBUG func claudeRawEventLoggingEnabled() -> Bool { defaults.bool(forKey: "claudeRawEventLoggingEnabled") diff --git a/Sources/RepoPrompt/Features/Settings/ViewModels/AgentModeComputerUseSettingsViewModel.swift b/Sources/RepoPrompt/Features/Settings/ViewModels/AgentModeComputerUseSettingsViewModel.swift new file mode 100644 index 000000000..5a7645650 --- /dev/null +++ b/Sources/RepoPrompt/Features/Settings/ViewModels/AgentModeComputerUseSettingsViewModel.swift @@ -0,0 +1,105 @@ +import AppKit +import Combine +import Foundation + +@MainActor +final class AgentModeComputerUseSettingsViewModel: ObservableObject { + @Published private(set) var status: CodexComputerUseStatus + @Published private(set) var isRefreshing = false + @Published private(set) var lastActionMessage: String? + + private let globalSettings: GlobalSettingsStore + private let statusService: CodexComputerUseStatusService + private let openURL: (URL) -> Void + private let pasteboard: NSPasteboard + private var refreshGeneration = 0 + + init( + globalSettings: GlobalSettingsStore = .shared, + statusService: CodexComputerUseStatusService = .shared, + openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) }, + pasteboard: NSPasteboard = .general + ) { + self.globalSettings = globalSettings + self.statusService = statusService + self.openURL = openURL + self.pasteboard = pasteboard + status = CodexComputerUseWorkflow.currentStatus( + persistedOptIn: globalSettings.codexComputerUseEnabled(), + statusService: statusService + ) + } + + var optInEnabled: Bool { + globalSettings.codexComputerUseEnabled() + } + + func refresh() { + refreshGeneration += 1 + let generation = refreshGeneration + isRefreshing = true + let refreshed = CodexComputerUseWorkflow.currentStatus( + persistedOptIn: globalSettings.codexComputerUseEnabled(), + statusService: statusService + ) + guard generation == refreshGeneration else { return } + status = refreshed + isRefreshing = false + } + + func setOptInEnabled(_ enabled: Bool) { + globalSettings.setCodexComputerUseEnabled(enabled) + refresh() + } + + func requestScreenRecordingAccess() { + let result = statusService.requestScreenRecordingAccess() + lastActionMessage = result.userMessage + refresh() + } + + func requestAccessibilityAccess() { + let result = statusService.requestAccessibilityAccess() + lastActionMessage = result.userMessage + refresh() + } + + func openScreenRecordingSettings() { + openURL(Self.screenRecordingSettingsURL) + lastActionMessage = "Opened Screen Recording settings. Grant access, then return to RepoPrompt and refresh status." + } + + func openAccessibilitySettings() { + openURL(Self.accessibilitySettingsURL) + lastActionMessage = "Opened Accessibility settings. Grant access, then return to RepoPrompt and refresh status." + } + + func openCodexComputerUseGuide() { + openURL(Self.codexComputerUseDocsURL) + lastActionMessage = "Opened the Codex Computer Use setup guide." + } + + func copyManualSetupInstructions(for requirement: CodexComputerUsePrerequisite) { + let instructions = manualSetupInstructions(for: requirement) + pasteboard.clearContents() + pasteboard.setString(instructions, forType: .string) + lastActionMessage = "Copied setup instructions to the clipboard." + } + + private func manualSetupInstructions(for requirement: CodexComputerUsePrerequisite) -> String { + switch requirement { + case .featureOptIn: + "Open RepoPrompt Settings → Agent Mode → Computer Use, then enable /computer-use." + case .plugin, .liveAvailability: + "Open Codex Settings → Computer Use, install or enable the Computer Use plugin, then return to RepoPrompt Settings → Agent Mode → Computer Use and click Refresh. RepoPrompt detects Codex app-managed Computer Use installs and legacy ~/.codex/config.toml entries; live tool availability may be reported as unknown when Codex does not expose a catalog." + case .screenRecording: + "Open System Settings → Privacy & Security → Screen & System Audio Recording (or Screen Recording), grant access to RepoPrompt, Codex, or the helper macOS lists, then restart RepoPrompt/Codex if prompted and click Refresh." + case .accessibility: + "Open System Settings → Privacy & Security → Accessibility, grant access to RepoPrompt, Codex, or the helper macOS lists, then click Refresh." + } + } + + private static let codexComputerUseDocsURL = URL(string: "https://developers.openai.com/codex/app/computer-use")! + private static let screenRecordingSettingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")! + private static let accessibilitySettingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! +} diff --git a/Sources/RepoPrompt/Features/Settings/Views/AgentModeComputerUseSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/AgentModeComputerUseSettingsView.swift new file mode 100644 index 000000000..0453ea6d3 --- /dev/null +++ b/Sources/RepoPrompt/Features/Settings/Views/AgentModeComputerUseSettingsView.swift @@ -0,0 +1,449 @@ +import SwiftUI + +private enum ComputerUseSetupRowTreatment { + case satisfied + case pending + case neutral + + var iconName: String { + switch self { + case .satisfied: "checkmark.circle.fill" + case .pending: "circle.dashed" + case .neutral: "info.circle.fill" + } + } + + var color: Color { + switch self { + case .satisfied: .green + case .pending: .orange + case .neutral: .secondary + } + } +} + +struct AgentModeComputerUseSettingsView: View { + @StateObject private var viewModel: AgentModeComputerUseSettingsViewModel + private let onNavigate: ((SettingsTab) -> Void)? + + init( + viewModel: AgentModeComputerUseSettingsViewModel? = nil, + onNavigate: ((SettingsTab) -> Void)? = nil + ) { + _viewModel = StateObject(wrappedValue: viewModel ?? AgentModeComputerUseSettingsViewModel()) + self.onNavigate = onNavigate + } + + private var status: CodexComputerUseStatus { + viewModel.status + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + headerSection + statusSection + setupSection + safetySection + } + .padding(20) + .frame(maxWidth: 900, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .onAppear { viewModel.refresh() } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Codex Computer Use") + .font(.title2) + .fontWeight(.semibold) + + Text("Set up the explicit /computer-use workflow for Codex Agent Mode. RepoPrompt exposes it only after setup is ready, even if you enable the opt-in first.") + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var statusSection: some View { + settingsCard { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: status.isReady ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(status.isReady ? .green : .orange) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(status.statusTitle) + .font(.system(size: 15, weight: .semibold)) + statusPill(status.isReady ? "Available" : "Unavailable", color: status.isReady ? .green : .orange) + } + + Text(status.statusDetail) + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if case .unsupported = status.liveAvailability { + Text("Live Codex tool availability cannot be verified before a turn in this build; static config and macOS permissions remain the setup gate.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else if case .unknown = status.liveAvailability { + Text(status.liveAvailability.detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 12) + + Button { + viewModel.refresh() + } label: { + Label(viewModel.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") + } + .controlSize(.small) + .disabled(viewModel.isRefreshing) + } + + Divider() + + Toggle(isOn: Binding(get: { viewModel.optInEnabled }, set: viewModel.setOptInEnabled)) { + VStack(alignment: .leading, spacing: 3) { + Text("Enable /computer-use in Agent Mode") + .font(.system(size: 13, weight: .semibold)) + Text(toggleDetailText) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .toggleStyle(.switch) + + HStack(spacing: 10) { + if let lastRefreshedAt = status.lastRefreshedAt { + Text("Last checked \(lastRefreshedAt.formatted(date: .omitted, time: .standard)).") + .font(.caption2) + .foregroundColor(.secondary) + } + if let message = viewModel.lastActionMessage { + Text(message) + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var setupSection: some View { + settingsCard { + VStack(alignment: .leading, spacing: 14) { + sectionHeader( + icon: "checklist", + title: "Setup Checklist", + detail: "Complete each prerequisite, then click Refresh. macOS may list RepoPrompt, Codex, or the helper that prompts for access; Screen Recording changes may require restarting RepoPrompt or Codex." + ) + + setupRow( + title: "Enable RepoPrompt Computer Use", + detail: "This opt-in can be enabled before setup is complete. /computer-use remains hidden until the rest of the checklist is ready.", + isSatisfied: status.optInEnabled, + pendingText: "Off", + satisfiedText: "On" + ) { + EmptyView() + } + + Divider() + + setupRow( + title: "Install or enable the Codex Computer Use plugin", + detail: status.pluginConfiguration.detail, + isSatisfied: status.pluginConfiguration.isConfigured, + pendingText: status.pluginConfiguration.title, + satisfiedText: status.pluginConfiguration.title + ) { + Button { + viewModel.openCodexComputerUseGuide() + } label: { + Label("Open Guide", systemImage: "book") + } + Button { + viewModel.copyManualSetupInstructions(for: .plugin) + } label: { + Label("Copy Steps", systemImage: "doc.on.doc") + } + } + + liveAvailabilityRow + + Divider() + + setupRow( + title: "Verify Screen Recording", + detail: screenRecordingDetailText, + isSatisfied: status.screenRecordingSatisfied, + pendingText: status.screenRecording.title, + satisfiedText: status.screenRecording.title + ) { + Button { + viewModel.requestScreenRecordingAccess() + } label: { + Label("Request", systemImage: "hand.raised") + } + Button { + viewModel.openScreenRecordingSettings() + } label: { + Label("Open Settings", systemImage: "rectangle.on.rectangle") + } + Button { + viewModel.copyManualSetupInstructions(for: .screenRecording) + } label: { + Label("Copy Steps", systemImage: "doc.on.doc") + } + } + + Divider() + + setupRow( + title: "Verify Accessibility", + detail: accessibilityDetailText, + isSatisfied: status.accessibilitySatisfied, + pendingText: status.accessibility.title, + satisfiedText: status.accessibility.title + ) { + Button { + viewModel.requestAccessibilityAccess() + } label: { + Label("Request", systemImage: "hand.raised") + } + Button { + viewModel.openAccessibilitySettings() + } label: { + Label("Open Settings", systemImage: "hand.point.up.left") + } + Button { + viewModel.copyManualSetupInstructions(for: .accessibility) + } label: { + Label("Copy Steps", systemImage: "doc.on.doc") + } + } + } + } + } + + private var liveAvailabilityRow: some View { + setupRow( + title: "Verify live Computer Use tool availability", + detail: status.liveAvailability.detail, + isSatisfied: !status.liveAvailability.blocksReadiness, + pendingText: status.liveAvailability.title, + satisfiedText: status.liveAvailability.title, + treatment: liveAvailabilityTreatment + ) { + Button { + viewModel.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + Button { + viewModel.copyManualSetupInstructions(for: .liveAvailability) + } label: { + Label("Copy Steps", systemImage: "doc.on.doc") + } + } + } + + private var liveAvailabilityTreatment: ComputerUseSetupRowTreatment { + switch status.liveAvailability { + case .available: + .satisfied + case .unavailable: + .pending + case .unknown, .unsupported: + .neutral + } + } + + private var screenRecordingDetailText: String { + if status.usesCodexManagedMacPermissions, !status.screenRecording.isGranted { + return "Codex Computer Use is app-managed, so RepoPrompt does not need its own Screen Recording permission. If Codex itself blocks, grant access to Codex Computer Use in System Settings." + } + return "Allows Codex computer use to see target app windows and browser content while a task runs." + } + + private var accessibilityDetailText: String { + if status.usesCodexManagedMacPermissions, !status.accessibility.isGranted { + return "Codex Computer Use is app-managed, so RepoPrompt does not need its own Accessibility permission. If Codex itself blocks, grant access to Codex Computer Use in System Settings." + } + return "Allows Codex computer use to click, type, and navigate target apps after you approve app access." + } + + private var safetySection: some View { + settingsCard { + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + icon: "lock.shield", + title: "Safety Boundaries", + detail: "Computer Use adds desktop automation, but it does not collapse the existing approval layers." + ) + + safetyBoundaryRow( + icon: "macwindow", + title: "macOS permissions", + detail: "Screen Recording and Accessibility let Codex see and operate apps; they do not grant access to every app automatically." + ) + safetyBoundaryRow( + icon: "app.badge.checkmark", + title: "Allowed target apps", + detail: "Codex asks before using an app. Keep the allow-list narrow and revoke Always Allow entries in Codex Settings when they are no longer needed." + ) + safetyBoundaryRow( + icon: "checkmark.bubble", + title: "Codex approvals", + detail: "Codex can still ask for sensitive or disruptive action approval during a Computer Use task. Review those prompts separately from RepoPrompt MCP approvals." + ) + safetyBoundaryRow( + icon: "shippingbox", + title: "RepoPrompt sandbox policy", + detail: "File reads, file edits, and shell commands continue to follow the selected Codex sandbox and approval policy for this Agent Mode session." + ) + safetyBoundaryRow( + icon: "exclamationmark.octagon", + title: "Destructive-action confirmations", + detail: "Purchasing, sending, publishing, account-changing, deleting, or externally visible actions still require explicit user confirmation." + ) + + HStack(spacing: 10) { + Button { + onNavigate?(.agentPermissions) + } label: { + Label("Review Agent Permissions", systemImage: "lock.shield") + } + Button { + onNavigate?(.cliProviders) + } label: { + Label("Review CLI Providers", systemImage: "terminal") + } + } + .buttonStyle(.borderless) + .controlSize(.small) + } + } + } + + private var toggleDetailText: String { + if status.isReady { + return "When enabled, /computer-use appears for Codex sessions and activates Computer Use for one explicit turn." + } + if status.optInEnabled { + return "Enabled, but /computer-use stays hidden until Codex configuration and macOS permissions are ready." + } + return "You can enable this now; RepoPrompt will expose /computer-use only after setup is complete." + } + + private func setupRow( + title: String, + detail: String, + isSatisfied: Bool, + pendingText: String, + satisfiedText: String, + treatment: ComputerUseSetupRowTreatment? = nil, + @ViewBuilder actions: () -> some View + ) -> some View { + let rowTreatment = treatment ?? (isSatisfied ? .satisfied : .pending) + return HStack(alignment: .top, spacing: 12) { + Image(systemName: rowTreatment.iconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(rowTreatment.color) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + statusPill(isSatisfied ? satisfiedText : pendingText, color: rowTreatment.color) + } + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + HStack(spacing: 8) { + actions() + } + .buttonStyle(.borderless) + .controlSize(.small) + } + .padding(.vertical, 4) + } + + private func safetyBoundaryRow(icon: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundColor(.accentColor) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.vertical, 3) + } + + private func settingsCard(@ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + } + + private func sectionHeader(icon: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.accentColor) + .frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func statusPill(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundColor(color) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(color.opacity(0.12)) + .clipShape(Capsule()) + } +} diff --git a/Sources/RepoPrompt/Features/Settings/Views/AgentModeGeneralSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/AgentModeGeneralSettingsView.swift index bc3cc979a..2394b3e36 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/AgentModeGeneralSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/AgentModeGeneralSettingsView.swift @@ -107,6 +107,8 @@ struct AgentModeGeneralSettingsView: View { providersLinkRow + agentComputerUseLinkRow + agentPermissionsCard linkRow( @@ -120,6 +122,21 @@ struct AgentModeGeneralSettingsView: View { } } + // MARK: - Computer Use row + + private var agentComputerUseLinkRow: some View { + let status = CodexComputerUseWorkflow.availability + let detail = status.isReady + ? "Ready for explicit /computer-use turns. Codex app access, sensitive actions, and RepoPrompt sandbox approvals still remain separate." + : status.primaryUnavailableMessage + return linkRow( + icon: "display", + title: "Computer Use", + detail: detail, + tab: .agentComputerUse + ) + } + // MARK: - Agent Workflows row /// Link row for the canonical Agent Workflows settings page. The cleanup-guidance diff --git a/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift index 8c5248d8a..16d44364e 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/SettingsView.swift @@ -246,7 +246,7 @@ struct SettingsView: View { // Overview (formerly "Behavior") is the top-level entry point that // deep-links into each of the other Agent Mode settings surfaces, so // it sits first in the sidebar. - [.agentMode, .cliProviders, .agentModels, .agentPermissions, .agentWorkflows, .contextBuilder] + [.agentMode, .cliProviders, .agentModels, .agentPermissions, .agentComputerUse, .agentWorkflows, .contextBuilder] case .mcp: [.mcp, .mcpTools, .permissions, .modelPresets] case .api: @@ -418,6 +418,11 @@ struct SettingsView: View { onNavigate: { tab in selectedTab = tab } ) .transition(.opacity.animation(.easeInOut(duration: 0.15))) + case .agentComputerUse: + AgentModeComputerUseSettingsView( + onNavigate: { tab in selectedTab = tab } + ) + .transition(.opacity.animation(.easeInOut(duration: 0.15))) case .agentWorkflows: AgentModeWorkflowsSettingsView( workflowStore: .shared, @@ -534,6 +539,7 @@ enum SettingsTab: String, CaseIterable { case agentMode // Agent Mode "Overview" tab (formerly labeled "Agent Mode Behavior") case agentModels // NEW: Unified model config shell (Phase 1 IA scaffolding) case agentPermissions // NEW: Unified permissions shell (Phase 1 IA scaffolding) + case agentComputerUse // Codex Computer Use setup and prerequisite status case agentWorkflows // Agent Mode workflow prompts and featured/custom workflows var title: String { @@ -563,6 +569,7 @@ enum SettingsTab: String, CaseIterable { case .agentMode: "Overview" case .agentModels: "Agent Models" case .agentPermissions: "Agent Permissions" + case .agentComputerUse: "Computer Use" case .agentWorkflows: "Agent Workflows" } } @@ -594,6 +601,7 @@ enum SettingsTab: String, CaseIterable { case .agentMode: "brain.head.profile" case .agentModels: "brain" case .agentPermissions: "lock.shield" + case .agentComputerUse: "display" case .agentWorkflows: "bolt.fill" } } @@ -604,6 +612,7 @@ enum SettingsTab: String, CaseIterable { case .cliProviders, .agentModels, .agentPermissions, + .agentComputerUse, .agentWorkflows, .contextBuilder, .agentMode: @@ -1132,6 +1141,23 @@ enum SettingsTab: String, CaseIterable { "permission profile", "provider permissions" ] + case .agentComputerUse: + [ + "computer use", + "codex computer use", + "desktop automation", + "screen recording", + "accessibility", + "macos permissions", + "system settings", + "codex plugin", + "computer-use plugin", + "allowed apps", + "target apps", + "codex approvals", + "destructive actions", + "/computer-use" + ] case .agentWorkflows: [ "agent workflows", diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppOnly/CodexComputerUseStatusService.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppOnly/CodexComputerUseStatusService.swift new file mode 100644 index 000000000..6d454b4b6 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppOnly/CodexComputerUseStatusService.swift @@ -0,0 +1,160 @@ +import ApplicationServices +import CoreGraphics +import Foundation + +struct CodexComputerUsePermissionClient { + var screenRecordingStatus: () -> CodexComputerUsePermissionStatus + var accessibilityStatus: () -> CodexComputerUsePermissionStatus + var requestScreenRecording: () -> CodexComputerUsePermissionRequestResult + var requestAccessibility: () -> CodexComputerUsePermissionRequestResult + + static let production = CodexComputerUsePermissionClient( + screenRecordingStatus: { + CGPreflightScreenCaptureAccess() ? .granted : .notGranted + }, + accessibilityStatus: { + AXIsProcessTrusted() ? .granted : .notGranted + }, + requestScreenRecording: { + CGRequestScreenCaptureAccess() ? .granted : .promptShownRefreshRequired + }, + requestAccessibility: { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) ? .granted : .promptShownRefreshRequired + } + ) +} + +struct CodexComputerUseStatusService { + struct Dependencies { + var configProbe: () -> CodexComputerUsePluginConfigurationStatus + var permissionClient: CodexComputerUsePermissionClient + var liveAvailabilityProbe: () -> CodexComputerUseLiveAvailability + var now: () -> Date + + static let production = Dependencies( + configProbe: CodexComputerUseStatusService.productionConfigProbe, + permissionClient: .production, + liveAvailabilityProbe: { + .unsupported(reason: CodexComputerUseStatus.defaultLiveAvailabilityUnsupportedReason) + }, + now: Date.init + ) + } + + static var shared = CodexComputerUseStatusService() + + private var dependencies: Dependencies + + init(dependencies: Dependencies = .production) { + self.dependencies = dependencies + } + + func currentStatus(optInEnabled: Bool, includeTimestamp: Bool = true) -> CodexComputerUseStatus { + CodexComputerUseStatus( + optInEnabled: optInEnabled, + prerequisites: prerequisiteSnapshot(), + lastRefreshedAt: includeTimestamp ? dependencies.now() : nil + ) + } + + func prerequisiteSnapshot() -> CodexComputerUsePrerequisiteSnapshot { + #if DEBUG + if let snapshot = Self.testingPrerequisiteSnapshot { + return snapshot + } + #endif + + return CodexComputerUsePrerequisiteSnapshot( + pluginConfiguration: dependencies.configProbe(), + liveAvailability: dependencies.liveAvailabilityProbe(), + screenRecording: dependencies.permissionClient.screenRecordingStatus(), + accessibility: dependencies.permissionClient.accessibilityStatus() + ) + } + + func requestScreenRecordingAccess() -> CodexComputerUsePermissionRequestResult { + dependencies.permissionClient.requestScreenRecording() + } + + func requestAccessibilityAccess() -> CodexComputerUsePermissionRequestResult { + dependencies.permissionClient.requestAccessibility() + } + + static func productionConfigProbe() -> CodexComputerUsePluginConfigurationStatus { + configProbe( + configURL: CodexIntegrationConfiguration.configURL(), + codexDirectoryURL: CodexIntegrationConfiguration.configDirectoryURL() + ) + } + + static func configProbe( + configURL: URL, + codexDirectoryURL: URL + ) -> CodexComputerUsePluginConfigurationStatus { + switch CodexComputerUseRuntimeConfiguration.resolve( + configURL: configURL, + codexDirectoryURL: codexDirectoryURL + ) { + case let .resolved(configuration): + switch configuration.source { + case .explicitMCPServer: + .configured(serverName: configuration.serverName) + case let .appManagedBundledPlugin(mcpConfigPath, _, version): + .appManagedPluginInstalled(path: mcpConfigPath, version: version) + } + case let .incomplete(incomplete): + .incomplete(path: incomplete.path, message: incomplete.message) + case let .missingConfigFile(path): + .missingConfigFile(path: path) + case let .serverEntryMissing(path): + .serverEntryMissing(path: path) + case let .unreadable(path, message): + .unreadable(path: path, message: message) + } + } +} + +#if DEBUG + extension CodexComputerUseStatusService { + private static let testingPrerequisiteLock = NSLock() + private static var testingPrerequisiteSnapshotStorage: CodexComputerUsePrerequisiteSnapshot? + + private static var testingPrerequisiteSnapshot: CodexComputerUsePrerequisiteSnapshot? { + testingPrerequisiteLock.lock() + defer { testingPrerequisiteLock.unlock() } + return testingPrerequisiteSnapshotStorage + } + + static func setPrerequisiteSnapshotForTesting(_ snapshot: CodexComputerUsePrerequisiteSnapshot?) { + testingPrerequisiteLock.lock() + testingPrerequisiteSnapshotStorage = snapshot + testingPrerequisiteLock.unlock() + } + + static func test_configDeclaresComputerUsePlugin(_ content: String) -> Bool { + CodexComputerUseRuntimeConfiguration.configDeclaresAppManagedPlugin(content) + } + + static func testing( + configProbe: @escaping () -> CodexComputerUsePluginConfigurationStatus = { .configured(serverName: CodexComputerUseConstants.mcpServerName) }, + permissionClient: CodexComputerUsePermissionClient = .init( + screenRecordingStatus: { .granted }, + accessibilityStatus: { .granted }, + requestScreenRecording: { .granted }, + requestAccessibility: { .granted } + ), + liveAvailabilityProbe: @escaping () -> CodexComputerUseLiveAvailability = { .unsupported(reason: CodexComputerUseStatus.defaultLiveAvailabilityUnsupportedReason) }, + now: @escaping () -> Date = { Date(timeIntervalSince1970: 0) } + ) -> CodexComputerUseStatusService { + CodexComputerUseStatusService( + dependencies: .init( + configProbe: configProbe, + permissionClient: permissionClient, + liveAvailabilityProbe: liveAvailabilityProbe, + now: now + ) + ) + } + } +#endif diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index f474eb200..1e25e5b57 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -252,6 +252,13 @@ actor CodexAppServerClient { } } + static func timeoutFailureMessage(method: String?, requestID: String, timeout: TimeInterval) -> String { + let timeoutText = timeout.rounded(.towardZero) == timeout ? String(Int(timeout)) : String(timeout) + let trimmedMethod = method?.trimmingCharacters(in: .whitespacesAndNewlines) + let methodText = trimmedMethod?.isEmpty == false ? trimmedMethod! : "" + return "Codex app-server JSON-RPC request timed out after \(timeoutText)s (method: \(methodText), request id: \(requestID))." + } + private var config = Config() private var process: SpawnedProcess? private var stdoutChunkChannel: FileHandleChunkChannel? @@ -277,6 +284,7 @@ actor CodexAppServerClient { /// newly started process. private var transportGeneration: UInt64 = 0 private var startupTask: (id: UUID, task: Task)? + private var diagnosticsLogger: CodexAppServerDiagnostics.Logger? private var expectedAgentPIDRegistration: ExpectedAgentPIDRegistration? private var registeredExpectedAgentPID: RegisteredExpectedAgentPID? private static let maxDecodeRecoveryAttemptsPerGeneration = 128 @@ -300,6 +308,11 @@ actor CodexAppServerClient { func updateConfig(_ config: Config) { self.config = config + refreshDiagnosticsLoggerFromDefaults() + } + + private func refreshDiagnosticsLoggerFromDefaults() { + diagnosticsLogger = CodexAppServerDiagnostics.makeLoggerIfEnabled() } func setExpectedAgentPIDRegistration(_ registration: ExpectedAgentPIDRegistration?) async { @@ -567,6 +580,9 @@ actor CodexAppServerClient { private func finishTransportTermination(_ terminatingTransport: TerminatingTransport?) async { guard let terminatingTransport else { return } + diagnosticsLogger?.record(kind: "transport.terminate", payload: [ + "reason": String(describing: lastTransportTerminationReason ?? TransportTerminationReason.explicitStop) + ]) if let expectedAgentPIDToClear = terminatingTransport.expectedAgentPIDToClear { await expectedAgentPIDRegistrar.clear( expectedAgentPIDToClear.pid, @@ -799,6 +815,7 @@ actor CodexAppServerClient { } private func startProcess() async throws { + refreshDiagnosticsLoggerFromDefaults() let environmentResult = await ProcessEnvironmentBuilder.build( ProcessEnvironmentRequest( purpose: .codexAppServer, @@ -812,6 +829,14 @@ actor CodexAppServerClient { additionalPathHints: config.additionalPathHints, logger: config.enableDebugLogging ? { print("[CodexAppServer] \($0)") } : nil ) + diagnosticsLogger?.record(kind: "runtime.executableResolution", payload: [ + "commandName": resolution.commandName, + "resolvedCommand": resolution.resolvedCommand, + "status": String(describing: resolution.status), + "additionalPathHints": resolution.additionalPathHints, + "pathPresent": resolution.pathValue?.isEmpty == false, + "userMessage": resolution.userMessage + ]) guard resolution.status == .available else { throw ClientError.executableUnavailable(resolution.userMessage) } @@ -820,12 +845,22 @@ actor CodexAppServerClient { featurePolicy: config.processFeaturePolicy ) let args = processOverrides + ["app-server"] + diagnosticsLogger?.record(kind: "process.start", payload: [ + "command": resolution.resolvedCommand, + "arguments": args, + "workingDirectory": config.workingDirectory ?? "", + "environmentKeys": Array(environment.keys).sorted(), + "featurePolicy": String(describing: config.processFeaturePolicy) + ]) let spawned = try ProcessLauncher.spawn( command: resolution.resolvedCommand, arguments: args, environment: environment, workingDirectory: config.workingDirectory ) + diagnosticsLogger?.record(kind: "process.started", payload: [ + "pid": Int(spawned.pid) + ]) stdoutFramer = LineFramer() stdoutTail.removeAll(keepingCapacity: false) didTerminateTransport = false @@ -899,6 +934,7 @@ actor CodexAppServerClient { let channel = FileHandleChunkChannel() stderrChunkChannel = channel let enableDebugLogging = config.enableDebugLogging + let diagnosticsLogger = diagnosticsLogger handle.readabilityHandler = { readable in let data = readable.availableData if data.isEmpty { @@ -911,6 +947,10 @@ actor CodexAppServerClient { stderrConsumerTask = Task { [weak self] in for await chunk in channel.stream { guard self != nil else { break } + diagnosticsLogger?.record( + kind: "process.stderr", + payload: CodexAppServerDiagnostics.lineRecordPayload(from: chunk) + ) if enableDebugLogging, let line = String(data: chunk, encoding: .utf8), !line.isEmpty @@ -1024,8 +1064,13 @@ actor CodexAppServerClient { let idString = String(describing: idValue) if let result = json["result"] as? [String: Any] { if let continuation = pendingRequests.removeValue(forKey: idString) { - pendingRequestMetadata.removeValue(forKey: idString) + let metadata = pendingRequestMetadata.removeValue(forKey: idString) cancelTimeout(for: idString) + diagnosticsLogger?.record(kind: "jsonrpc.response", payload: [ + "requestID": idString, + "method": metadata?.method ?? "", + "resultKeys": Array(result.keys).sorted() + ]) if config.enableDebugLogging { print("[CodexAppServer] Response for request \(idString)") } @@ -1037,8 +1082,13 @@ actor CodexAppServerClient { let message = error["message"] as? String { if let continuation = pendingRequests.removeValue(forKey: idString) { - pendingRequestMetadata.removeValue(forKey: idString) + let metadata = pendingRequestMetadata.removeValue(forKey: idString) cancelTimeout(for: idString) + diagnosticsLogger?.record(kind: "jsonrpc.error", payload: [ + "requestID": idString, + "method": metadata?.method ?? "", + "message": message + ]) if config.enableDebugLogging { print("[CodexAppServer] Error for request \(idString): \(message)") } @@ -1050,6 +1100,12 @@ actor CodexAppServerClient { let requestID = CodexAppServerRequestID(raw: idValue) { let params = codexJSONDictionary(from: json["params"] as? [String: Any] ?? [:]) + diagnosticsLogger?.record(kind: "jsonrpc.serverRequest", payload: [ + "requestID": requestID.displayValue, + "method": method, + "listenerCount": serverRequestContinuations.count, + "params": CodexAppServerDiagnostics.valueSummary(params.mapValues { $0.toAny() }) + ]) if config.enableDebugLogging { print("[CodexAppServer] Server request: \(method) -> broadcasting to \(serverRequestContinuations.count) listeners") } @@ -1065,6 +1121,11 @@ actor CodexAppServerClient { } if let method = json["method"] as? String { let params = json["params"] as? [String: Any] ?? [:] + diagnosticsLogger?.record(kind: "jsonrpc.notification", payload: [ + "method": method, + "listenerCount": notificationContinuations.count, + "params": CodexAppServerDiagnostics.valueSummary(params) + ]) if config.enableDebugLogging { print("[CodexAppServer] Notification: \(method) -> broadcasting to \(notificationContinuations.count) listeners") } @@ -1293,6 +1354,10 @@ actor CodexAppServerClient { private func sendJSONLine(_ payload: [String: Any], method: String?) throws { guard let process else { throw ClientError.processNotRunning } let data = try JSONSerialization.data(withJSONObject: payload, options: []) + diagnosticsLogger?.record(kind: "jsonrpc.send", payload: [ + "method": method ?? "", + "payload": CodexAppServerDiagnostics.jsonRPCPayloadSummary(payload) + ]) if config.enableDebugLogging { if let line = String(data: data, encoding: .utf8) { print("[CodexAppServer] -> \(line)") @@ -1403,7 +1468,18 @@ actor CodexAppServerClient { ) scheduleTransportCleanup(terminatingTransport) } - continuation.resume(throwing: ClientError.requestFailed("Request timed out after \(timeout)s")) + let message = Self.timeoutFailureMessage( + method: metadata?.method, + requestID: id, + timeout: timeout + ) + diagnosticsLogger?.record(kind: "jsonrpc.timeout", payload: [ + "requestID": id, + "method": metadata?.method ?? "", + "timeoutSeconds": timeout, + "poisonedTransport": metadata.map { Self.shouldPoisonTransportOnTimeout(method: $0.method) } ?? false + ]) + continuation.resume(throwing: ClientError.requestFailed(message)) } private func cancelTimeout(for requestID: String) { diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerDiagnostics.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerDiagnostics.swift new file mode 100644 index 000000000..f5d9110ae --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerDiagnostics.swift @@ -0,0 +1,255 @@ +import Foundation + +enum CodexAppServerDiagnostics { + static let appServerDiagnosticsEnabledKey = "codexAppServerDiagnosticsEnabled" + static let appServerDiagnosticsLogFilePathKey = "codexAppServerDiagnosticsLogFilePath" + static let lastAppServerDiagnosticsLogFilePathKey = "codexLastAppServerDiagnosticsLogFilePath" + static let rawEventLoggingEnabledKey = "codexRawEventLoggingEnabled" + static let rawEventLogFilePathKey = "codexRawEventLogFilePath" + static let lastRawEventLogFilePathKey = "codexLastRawEventLogFilePath" + + private static let sensitiveKeyFragments = [ + "api_key", "apikey", "authorization", "bearer", "cookie", "credential", "keychain", + "password", "refresh", "secret", "session", "token" + ] + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + final class Logger: @unchecked Sendable { + private let fileURL: URL + private let runID: UUID + private let lock = NSLock() + + init(fileURL: URL, runID: UUID = UUID()) { + self.fileURL = fileURL + self.runID = runID + } + + func record(kind: String, payload: [String: Any] = [:]) { + var record: [String: Any] = [ + "kind": kind, + "timestamp": CodexAppServerDiagnostics.timestampFormatter.string(from: Date()), + "runID": runID.uuidString + ] + if !payload.isEmpty { + record["payload"] = CodexAppServerDiagnostics.sanitizedJSONObject(payload) + } + append(record) + } + + private func append(_ record: [String: Any]) { + guard JSONSerialization.isValidJSONObject(record), + let data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]), + var line = String(data: data, encoding: .utf8) + else { + return + } + line.append("\n") + guard let lineData = line.data(using: .utf8) else { return } + + lock.lock() + defer { lock.unlock() } + do { + if !FileManager.default.fileExists(atPath: fileURL.path) { + _ = FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + handle.write(lineData) + } catch { + return + } + } + } + + static func appServerDiagnosticsEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.bool(forKey: appServerDiagnosticsEnabledKey) + } + + static func rawEventLoggingEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.bool(forKey: rawEventLoggingEnabledKey) + } + + static func appServerDiagnosticsLogFilePath(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: appServerDiagnosticsLogFilePathKey) ?? "" + } + + static func setAppServerDiagnosticsLogFilePath(_ path: String, defaults: UserDefaults = .standard) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: appServerDiagnosticsLogFilePathKey) + } else { + defaults.set(path, forKey: appServerDiagnosticsLogFilePathKey) + } + } + + static func rawEventLogFilePath(defaults: UserDefaults = .standard) -> String { + defaults.string(forKey: rawEventLogFilePathKey) ?? "" + } + + static func setRawEventLogFilePath(_ path: String, defaults: UserDefaults = .standard) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: rawEventLogFilePathKey) + } else { + defaults.set(path, forKey: rawEventLogFilePathKey) + } + } + + static func makeLoggerIfEnabled(defaults: UserDefaults = .standard) -> Logger? { + guard appServerDiagnosticsEnabled(defaults: defaults) else { return nil } + guard let fileURL = makeLogFileURL( + overridePath: appServerDiagnosticsLogFilePath(defaults: defaults), + defaultSubdirectory: "RepoPrompt/codex-app-server-diagnostics", + filePrefix: "codex-app-server-diagnostics" + ) else { + return nil + } + defaults.set(fileURL.path, forKey: lastAppServerDiagnosticsLogFilePathKey) + return Logger(fileURL: fileURL) + } + + static func makeLogFileURL( + overridePath: String?, + defaultSubdirectory: String, + filePrefix: String + ) -> URL? { + let directory: URL + let trimmedOverride = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedOverride.isEmpty { + directory = URL(fileURLWithPath: NSString(string: trimmedOverride).expandingTildeInPath, isDirectory: true) + } else { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent(defaultSubdirectory, isDirectory: true) + } + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return nil + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMdd-HHmmss" + let fileName = "\(filePrefix)-\(formatter.string(from: Date())).jsonl" + return directory.appendingPathComponent(fileName) + } + + static func jsonRPCPayloadSummary(_ payload: [String: Any]) -> [String: Any] { + var summary: [String: Any] = [ + "keys": Array(payload.keys).sorted() + ] + if let method = payload["method"] as? String { + summary["method"] = method + } + if let id = payload["id"] { + summary["id"] = sanitizedString(String(describing: id), maxLength: 120) + } + if let params = payload["params"] { + summary["params"] = valueSummary(params) + } + if let result = payload["result"] { + summary["result"] = valueSummary(result) + } + if let error = payload["error"] { + summary["error"] = valueSummary(error) + } + return summary + } + + static func valueSummary(_ value: Any) -> Any { + if let dictionary = value as? [String: Any] { + let keys = Array(dictionary.keys).sorted() + var summary: [String: Any] = [ + "type": "object", + "keyCount": dictionary.count, + "keys": Array(keys.prefix(80)) + ] + if dictionary.count > 80 { + summary["truncatedKeys"] = dictionary.count - 80 + } + return summary + } + if let array = value as? [Any] { + return [ + "type": "array", + "count": array.count + ] + } + if let string = value as? String { + return [ + "type": "string", + "characters": string.count, + "isEmpty": string.isEmpty + ] + } + if value is NSNull { + return ["type": "null"] + } + return [ + "type": String(describing: type(of: value)) + ] + } + + static func sanitizedJSONObject(_ value: Any, keyPath _: [String] = []) -> Any { + if let dictionary = value as? [String: Any] { + var output: [String: Any] = [:] + for key in dictionary.keys.sorted().prefix(80) { + guard let child = dictionary[key] else { continue } + if isSensitiveKey(key) { + output[key] = "" + } else { + output[key] = sanitizedJSONObject(child) + } + } + if dictionary.count > 80 { + output["_truncatedKeys"] = dictionary.count - 80 + } + return output + } + if let array = value as? [Any] { + var output = array.prefix(40).map { sanitizedJSONObject($0) } + if array.count > 40 { + output.append("") + } + return output + } + if let string = value as? String { + return sanitizedString(string) + } + if let number = value as? NSNumber { + return number + } + if value is NSNull { + return NSNull() + } + return sanitizedString(String(describing: value)) + } + + static func sanitizedString(_ raw: String, maxLength: Int = 1200) -> String { + var output = CommandExecutionOutputSanitizer.sanitize(raw) + output = output.replacingOccurrences(of: "\0", with: "") + if output.count > maxLength { + let prefix = output.prefix(maxLength) + return "\(prefix)…[\(output.count) chars]" + } + return output + } + + static func lineRecordPayload(from data: Data, maxLength: Int = 1200) -> [String: Any] { + let text = String(data: data, encoding: .utf8) ?? "" + return [ + "byteCount": data.count, + "text": sanitizedString(text, maxLength: maxLength) + ] + } + + private static func isSensitiveKey(_ key: String) -> Bool { + let normalized = key.lowercased() + return sensitiveKeyFragments.contains { normalized.contains($0) } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index bedb70e18..e210c0769 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -62,9 +62,10 @@ final class CodexNativeSessionController { private static let maxRunningAggregatedOutputCharacters = 24000 private static let computerUseMCPServerName = "computer-use" + private static let computerUseMCPToolTimeoutSeconds = 10000 private static let runningOutputTruncationMarker = "\n...(output truncated)...\n" - private static let rawEventLogFilePathKey = "codexRawEventLogFilePath" - private static let lastRawEventLogFilePathKey = "codexLastRawEventLogFilePath" + private static let rawEventLogFilePathKey = CodexAppServerDiagnostics.rawEventLogFilePathKey + private static let lastRawEventLogFilePathKey = CodexAppServerDiagnostics.lastRawEventLogFilePathKey private static let rawEventTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -277,6 +278,9 @@ final class CodexNativeSessionController { var authTokensRefreshHandler: ChatgptAuthTokensRefreshHandler? var goalSupportEnabledProvider: @MainActor () -> Bool = { false } var computerUseEnabledProvider: @MainActor () -> Bool = { false } + var computerUseRuntimeConfigurationProvider: () -> CodexComputerUseRuntimeConfiguration.Resolution = { + CodexComputerUseRuntimeConfiguration.resolve() + } static func agentModeDefault( forceExperimentalSteering: Bool, @@ -285,7 +289,10 @@ final class CodexNativeSessionController { approvalReviewerProvider: @escaping () -> CodexAgentToolPreferences.ApprovalReviewer = { CodexAgentToolPreferences.approvalReviewer() }, shellToolEnabled: Bool? = nil, goalSupportEnabledProvider: @escaping @MainActor () -> Bool = { CodexGoalSupport.isEnabled }, - computerUseEnabledProvider: @escaping @MainActor () -> Bool = { false } + computerUseEnabledProvider: @escaping @MainActor () -> Bool = { false }, + computerUseRuntimeConfigurationProvider: @escaping () -> CodexComputerUseRuntimeConfiguration.Resolution = { + CodexComputerUseRuntimeConfiguration.resolve() + } ) -> Options { Options( requestTimeout: 120, @@ -296,6 +303,9 @@ final class CodexNativeSessionController { computerUseEnabled: computerUseEnabledProvider() ) } + let computerUseRuntimeResolution = featurePolicy.computerUseEnabled + ? computerUseRuntimeConfigurationProvider() + : nil return CodexNativeSessionController.defaultAppServerConfigOverrides( forceExperimentalSteering: forceExperimentalSteering, approvalPolicy: approvalPolicyProvider(), @@ -303,7 +313,8 @@ final class CodexNativeSessionController { approvalReviewer: approvalReviewerProvider(), shellToolEnabled: shellToolEnabled, goalSupportEnabled: featurePolicy.goalSupportEnabled, - computerUseEnabled: featurePolicy.computerUseEnabled + computerUseEnabled: featurePolicy.computerUseEnabled, + computerUseRuntimeConfigurationResolution: computerUseRuntimeResolution ) }, approvalPolicyProvider: approvalPolicyProvider, @@ -311,7 +322,8 @@ final class CodexNativeSessionController { approvalReviewerProvider: approvalReviewerProvider, authTokensRefreshHandler: nil, goalSupportEnabledProvider: goalSupportEnabledProvider, - computerUseEnabledProvider: computerUseEnabledProvider + computerUseEnabledProvider: computerUseEnabledProvider, + computerUseRuntimeConfigurationProvider: computerUseRuntimeConfigurationProvider ) } } @@ -347,6 +359,7 @@ final class CodexNativeSessionController { private let options: Options private let clientShutdownBehavior: ClientShutdownBehavior private let expectedMCPClientName: String? + private let appServerDiagnosticsLogger: CodexAppServerDiagnostics.Logger? private let rawEventFileLoggingEnabled: Bool private var rawEventLogFileURL: URL? private var rawEventLogFileThreadID: String? @@ -499,6 +512,7 @@ final class CodexNativeSessionController { self.options = options ?? Self.Options.agentModeDefault(forceExperimentalSteering: forceExperimentalSteering) self.clientShutdownBehavior = clientShutdownBehavior self.expectedMCPClientName = expectedMCPClientName + appServerDiagnosticsLogger = CodexAppServerDiagnostics.makeLoggerIfEnabled() rawEventFileLoggingEnabled = Self.isRawEventFileLoggingEnabled() rawEventLogFileURL = nil rawEventLogFileThreadID = nil @@ -628,7 +642,7 @@ final class CodexNativeSessionController { } private static func isRawEventFileLoggingEnabled() -> Bool { - false + CodexAppServerDiagnostics.rawEventLoggingEnabled() } private static func normalizedThreadIdentifier(_ raw: String?) -> String { @@ -762,6 +776,87 @@ final class CodexNativeSessionController { appendRawEventLogRecord(record) } + private func recordThreadConfigDiagnostics( + operation: String, + configOverrides: [String: Any], + model: String?, + reasoningEffort: String?, + serviceTier: String? + ) { + guard let appServerDiagnosticsLogger else { return } + appServerDiagnosticsLogger.record(kind: "thread.configSummary", payload: [ + "operation": operation, + "threadID": threadID ?? "", + "workspacePath": workspacePath ?? "", + "modelProvided": model?.isEmpty == false, + "reasoningEffortProvided": reasoningEffort?.isEmpty == false, + "serviceTier": serviceTier ?? "", + "configKeyCount": configOverrides.count, + "configKeys": Array(configOverrides.keys).sorted(), + "computerUse": Self.computerUseRuntimeDiagnosticSummary(from: configOverrides) + ]) + } + + private func recordComputerUseRuntimeResolutionDiagnosticsIfNeeded() async { + guard let appServerDiagnosticsLogger else { return } + let computerUseEnabled = await MainActor.run { options.computerUseEnabledProvider() } + guard computerUseEnabled else { + appServerDiagnosticsLogger.record(kind: "runtime.computerUseResolution", payload: [ + "enabled": false + ]) + return + } + let resolution = options.computerUseRuntimeConfigurationProvider() + appServerDiagnosticsLogger.record(kind: "runtime.computerUseResolution", payload: Self.computerUseRuntimeDiagnosticSummary(from: resolution)) + } + + private static func computerUseRuntimeDiagnosticSummary( + from resolution: CodexComputerUseRuntimeConfiguration.Resolution + ) -> [String: Any] { + switch resolution { + case let .resolved(configuration): + [ + "enabled": true, + "status": "resolved", + "serverName": configuration.serverName, + "source": String(describing: configuration.source), + "command": configuration.command, + "argsCount": configuration.args.count, + "cwd": configuration.cwd ?? "", + "envKeys": Array(configuration.env.keys).sorted(), + "toolTimeoutSec": configuration.toolTimeoutSec ?? NSNull() + ] + case let .incomplete(incomplete): + [ + "enabled": true, + "status": "incomplete", + "path": incomplete.path, + "message": incomplete.message + ] + case let .missingConfigFile(path): + ["enabled": true, "status": "missingConfigFile", "path": path] + case let .serverEntryMissing(path): + ["enabled": true, "status": "serverEntryMissing", "path": path] + case let .unreadable(path, message): + ["enabled": true, "status": "unreadable", "path": path, "message": message] + } + } + + private static func computerUseRuntimeDiagnosticSummary(from configOverrides: [String: Any]) -> [String: Any] { + let prefix = "mcp_servers.\(computerUseMCPServerName)." + let keys = configOverrides.keys.filter { $0.hasPrefix(prefix) }.sorted() + return [ + "featureEnabled": configOverrides["features.computer_use"] as? Bool ?? false, + "pluginsEnabled": configOverrides["features.plugins"] as? Bool ?? false, + "serverOverrideKeys": keys, + "serverEnabled": configOverrides["\(prefix)enabled"] as? Bool ?? false, + "hasCommandOverride": configOverrides["\(prefix)command"] != nil, + "hasCWDOverride": configOverrides["\(prefix)cwd"] != nil, + "hasEnvOverride": configOverrides["\(prefix)env"] != nil, + "toolTimeoutSec": configOverrides["\(prefix)tool_timeout_sec"] ?? NSNull() + ] + } + func startOrResume(existing: SessionRef?, baseInstructions: String) async throws -> SessionRef { try await startOrResume( existing: existing, @@ -812,6 +907,7 @@ final class CodexNativeSessionController { try await client.startIfNeeded() await ensureInboundStreamsStarted() + await recordComputerUseRuntimeResolutionDiagnosticsIfNeeded() let configOverrides = await options.configOverridesProvider() let pathValue = existing?.rolloutPath let existingID = existing?.conversationID ?? "" @@ -882,6 +978,14 @@ final class CodexNativeSessionController { } } + recordThreadConfigDiagnostics( + operation: existing != nil ? "thread/resume" : "thread/start", + configOverrides: configOverrides, + model: model, + reasoningEffort: reasoningEffort, + serviceTier: serviceTier + ) + let pendingSessionRef = Self.parseThreadSnapshot(from: result, fallbackEffort: reasoningEffort).sessionRef try await disableThreadMemoryMode(threadID: pendingSessionRef.conversationID) @@ -1293,17 +1397,13 @@ final class CodexNativeSessionController { private func applyThreadResponse(_ result: [String: Any], fallbackEffort: String?) -> SessionRef { let snapshot = Self.parseThreadSnapshot(from: result, fallbackEffort: fallbackEffort) restoreThreadSnapshot(snapshot) - #if DEBUG - ensureRawEventLogFileReadyIfNeeded() - #endif - #if DEBUG - writeRawEventLogRecord(kind: "session.threadReady", payload: [ - "conversationID": snapshot.conversationID, - "rolloutPath": snapshot.rolloutPath ?? NSNull(), - "activeTurnIDs": snapshot.activeTurnIDs, - "currentTurnID": snapshot.currentTurnID ?? NSNull() - ] as [String: Any]) - #endif + ensureRawEventLogFileReadyIfNeeded() + writeRawEventLogRecord(kind: "session.threadReady", payload: [ + "conversationID": snapshot.conversationID, + "rolloutPath": snapshot.rolloutPath ?? NSNull(), + "activeTurnIDs": snapshot.activeTurnIDs, + "currentTurnID": snapshot.currentTurnID ?? NSNull() + ] as [String: Any]) return snapshot.sessionRef } @@ -1681,9 +1781,7 @@ final class CodexNativeSessionController { private func handleNotification(_ notification: CodexAppServerClient.Notification) async { guard let threadID else { return } let params = decodeParams(notification.params) - #if DEBUG - writeRawEventLogRecord(kind: "notification.received", method: notification.method, payload: params) - #endif + writeRawEventLogRecord(kind: "notification.received", method: notification.method, payload: params) // Pre-register observed turn IDs before the routing drop decision to prevent // cascade drops when a lifecycle event (turn/started) was missed due to decode @@ -1708,9 +1806,7 @@ final class CodexNativeSessionController { currentTurnID: currentTurnID, activeTurnIDs: activeTurnIDs ) { - #if DEBUG - writeRawEventLogRecord(kind: "notification.dropped", method: notification.method, payload: params) - #endif + writeRawEventLogRecord(kind: "notification.dropped", method: notification.method, payload: params) return } let notifiedTurnID = Self.notificationTurnID(from: params) @@ -2393,6 +2489,14 @@ final class CodexNativeSessionController { return controller.parseExecCommandEndEvent(params: params)?.resultJSON } + static func test_isComputerUseMCPElicitationRequest(params: [String: Any]) -> Bool { + isComputerUseMCPElicitationRequest(params: params) + } + + func test_computerUseMCPElicitationAutoAcceptResult(params: [String: Any]) async -> [String: Any]? { + await computerUseMCPElicitationAutoAcceptResult(params: params) + } + struct TestToolLifecycleEvent: Equatable { let kind: String let name: String @@ -2496,9 +2600,13 @@ final class CodexNativeSessionController { private func handleServerRequest(_ request: CodexAppServerClient.ServerRequest) async { let method = request.method let params = decodeParams(request.params) - #if DEBUG - writeRawEventLogRecord(kind: "serverRequest.received", method: method, payload: params) - #endif + writeRawEventLogRecord(kind: "serverRequest.received", method: method, payload: params) + appServerDiagnosticsLogger?.record(kind: "serverRequest.received", payload: [ + "requestID": request.id.displayValue, + "method": method, + "routing": String(describing: Self.classifyServerRequestMethod(method)), + "params": CodexAppServerDiagnostics.valueSummary(params) + ]) switch Self.classifyServerRequestMethod(method) { case .approval: @@ -2613,15 +2721,17 @@ final class CodexNativeSessionController { } func respondToServerRequest(id: CodexAppServerRequestID, result: [String: Any]) async { - #if DEBUG - writeRawEventLogRecord( - kind: "serverRequest.respond", - payload: [ - "requestID": id.displayValue, - "result": result - ] - ) - #endif + writeRawEventLogRecord( + kind: "serverRequest.respond", + payload: [ + "requestID": id.displayValue, + "result": result + ] + ) + appServerDiagnosticsLogger?.record(kind: "serverRequest.respond", payload: [ + "requestID": id.displayValue, + "result": CodexAppServerDiagnostics.valueSummary(result) + ]) do { try await client.respondToServerRequest(id: id, result: result) } catch { @@ -2685,6 +2795,11 @@ final class CodexNativeSessionController { return } do { + appServerDiagnosticsLogger?.record(kind: "serverRequest.respond", payload: [ + "requestID": requestID.displayValue, + "method": method, + "resultKeys": Array(response.payload.keys).sorted() + ]) try await client.respondToServerRequest(id: requestID, result: response.payload) } catch { await emit(.error("Codex server request response failed: \(error.localizedDescription)")) @@ -3566,12 +3681,13 @@ final class CodexNativeSessionController { private func emit(_ event: Event) async { Self.logCodexDebug("[CodexNativeController] emit \(Self.debugEventSummary(event))") - #if DEBUG - writeRawEventLogRecord( - kind: "event.emit", - payload: ["summary": Self.debugEventSummary(event)] - ) - #endif + writeRawEventLogRecord( + kind: "event.emit", + payload: ["summary": Self.debugEventSummary(event)] + ) + appServerDiagnosticsLogger?.record(kind: "event.emit", payload: [ + "summary": Self.debugEventSummary(event) + ]) currentEventsContinuation()?.yield(event) } @@ -6407,10 +6523,13 @@ final class CodexNativeSessionController { approvalReviewer: CodexAgentToolPreferences.ApprovalReviewer? = nil, shellToolEnabled: Bool? = nil, goalSupportEnabled: Bool = false, - computerUseEnabled: Bool = false + computerUseEnabled: Bool = false, + computerUseRuntimeConfigurationResolution: CodexComputerUseRuntimeConfiguration.Resolution? = nil, + serverEntries injectedServerEntries: [MCPIntegrationHelper.CodexServerEntry]? = nil, + preferences injectedPreferences: CodexAgentToolPreferences.Snapshot? = nil ) -> [String: Any] { - let serverEntries = MCPIntegrationHelper.codexMCPServerEntries() - let preferences = CodexAgentToolPreferences.snapshot(for: serverEntries) + let serverEntries = injectedServerEntries ?? MCPIntegrationHelper.codexMCPServerEntries() + let preferences = injectedPreferences ?? CodexAgentToolPreferences.snapshot(for: serverEntries) let toolPolicy = CodexOverrides.ToolPolicy( toolOutputTokenLimit: MCPIntegrationHelper.desiredCodexToolOutputTokenLimit, shellToolEnabled: shellToolEnabled ?? preferences.bashToolEnabled, @@ -6426,16 +6545,14 @@ final class CodexNativeSessionController { toolPolicy: toolPolicy, featurePolicy: .resolved(goalsEnabled: goalSupportEnabled, computerUseEnabled: computerUseEnabled) ) - var enabledMCPServerNames = preferences.enabledMCPServerNames - if computerUseEnabled, - serverEntries.contains(where: { $0.normalizedName.caseInsensitiveCompare(Self.computerUseMCPServerName) == .orderedSame }) - { - enabledMCPServerNames.insert(Self.computerUseMCPServerName) - } + let computerUseRuntimeConfiguration = Self.resolvedComputerUseRuntimeConfiguration( + enabled: computerUseEnabled, + resolution: computerUseRuntimeConfigurationResolution + ) let mcpOverrides = CodexOverrides.appServerMCPServerMap( entries: serverEntries, policy: .enableSelected( - enabledNormalizedNames: enabledMCPServerNames, + enabledNormalizedNames: preferences.enabledMCPServerNames, repoPromptNormalizedName: MCPIntegrationHelper.repoPromptMCPServerName, exceptBroken: [] ) @@ -6443,6 +6560,12 @@ final class CodexNativeSessionController { for (key, value) in mcpOverrides { overrides[key] = value } + if let computerUseRuntimeConfiguration { + Self.applyComputerUseRuntimeOverrides( + to: &overrides, + configuration: computerUseRuntimeConfiguration + ) + } let effectiveApprovalPolicy = approvalPolicy ?? preferences.approvalPolicy let effectiveSandboxMode = sandboxMode ?? preferences.sandboxMode let effectiveApprovalReviewer = approvalReviewer ?? preferences.approvalReviewer @@ -6451,6 +6574,41 @@ final class CodexNativeSessionController { overrides["approvals_reviewer"] = effectiveApprovalReviewer.appServerConfigOverrideValue return overrides } + + private static func resolvedComputerUseRuntimeConfiguration( + enabled: Bool, + resolution: CodexComputerUseRuntimeConfiguration.Resolution? + ) -> CodexComputerUseRuntimeConfiguration? { + guard enabled else { return nil } + let effectiveResolution = resolution ?? CodexComputerUseRuntimeConfiguration.resolve() + guard case let .resolved(configuration) = effectiveResolution else { + return nil + } + return configuration + } + + private static func applyComputerUseRuntimeOverrides( + to overrides: inout [String: Any], + configuration: CodexComputerUseRuntimeConfiguration + ) { + let serverComponent = MCPIntegrationHelper.codexCLIPathComponent( + forNormalizedServerName: configuration.serverName + ) + let serverKeyPrefix = "mcp_servers.\(serverComponent)" + overrides["\(serverKeyPrefix).enabled"] = true + overrides["\(serverKeyPrefix).tool_timeout_sec"] = configuration.toolTimeoutSec ?? Self.computerUseMCPToolTimeoutSeconds + + guard case .appManagedBundledPlugin = configuration.source else { + return + } + + overrides["\(serverKeyPrefix).command"] = configuration.command + overrides["\(serverKeyPrefix).args"] = configuration.args + if let cwd = configuration.cwd { + overrides["\(serverKeyPrefix).cwd"] = cwd + } + overrides["\(serverKeyPrefix).env"] = configuration.env + } } extension CodexNativeSessionController: CodexSessionControlling {} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexIntegrationConfiguration.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexIntegrationConfiguration.swift index 02c9f4e16..76b540316 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexIntegrationConfiguration.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexIntegrationConfiguration.swift @@ -28,6 +28,45 @@ enum CodexIntegrationConfiguration { let wasRepoPromptServerPresent: Bool } + struct ServerConfiguration: Equatable { + let rawName: String + let normalizedName: String + let cliPathComponent: String + let command: String? + let args: [String]? + let cwd: String? + let env: [String: String] + let enabled: Bool? + let toolTimeoutSec: Int? + } + + private struct ServerConfigurationBuilder { + let rawName: String + let normalizedName: String + let cliPathComponent: String + var command: String? + var args: [String]? + var cwd: String? + var env: [String: String] = [:] + var enabled: Bool? + var toolTimeoutSec: Int? + var hasRootHeader = false + + var configuration: ServerConfiguration { + ServerConfiguration( + rawName: rawName, + normalizedName: normalizedName, + cliPathComponent: cliPathComponent, + command: command, + args: args, + cwd: cwd, + env: env, + enabled: enabled, + toolTimeoutSec: toolTimeoutSec + ) + } + } + private struct BlockRange { var start: Int var end: Int @@ -155,6 +194,88 @@ enum CodexIntegrationConfiguration { mcpServerEntries().map(\.normalizedName) } + static func mcpServerConfigurations(from content: String) -> [ServerConfiguration] { + mcpServerConfigurations(fromConfigContent: content) + } + + static func mcpServerConfiguration( + named targetName: String, + fromConfigContent content: String + ) -> ServerConfiguration? { + mcpServerConfigurations(fromConfigContent: content).first { + $0.normalizedName.caseInsensitiveCompare(targetName) == .orderedSame + } + } + + static func mcpServerConfigurations(fromConfigContent content: String) -> [ServerConfiguration] { + var buildersByName: [String: ServerConfigurationBuilder] = [:] + var orderedNames: [String] = [] + var activeRootName: String? + var activeEnvName: String? + + func ensureBuilder(for component: TOMLKeyComponent, hasRootHeader: Bool) { + if buildersByName[component.normalized] == nil { + orderedNames.append(component.normalized) + buildersByName[component.normalized] = ServerConfigurationBuilder( + rawName: component.raw, + normalizedName: component.normalized, + cliPathComponent: cliPathComponent(forNormalizedServerName: component.normalized), + hasRootHeader: hasRootHeader + ) + } else if hasRootHeader { + buildersByName[component.normalized]?.hasRootHeader = true + } + } + + for line in splitTOMLLines(content) { + if let header = parseTOMLHeader(line) { + activeRootName = nil + activeEnvName = nil + guard !header.isArrayTable, + header.keyPath.count >= 2, + header.keyPath[0].normalized == "mcp_servers" + else { + continue + } + + let serverComponent = header.keyPath[1] + if header.keyPath.count == 2 { + ensureBuilder(for: serverComponent, hasRootHeader: true) + activeRootName = serverComponent.normalized + } else if header.keyPath.count == 3, header.keyPath[2].normalized == "env" { + ensureBuilder(for: serverComponent, hasRootHeader: false) + activeEnvName = serverComponent.normalized + } + continue + } + + guard let assignment = parseTOMLAssignment(line) else { continue } + if let serverName = activeRootName { + if assignment.isSingleKey("command") { + buildersByName[serverName]?.command = parseTOMLStringValue(assignment.valueText) + } else if assignment.isSingleKey("args") { + buildersByName[serverName]?.args = parseTOMLStringArrayValue(assignment.valueText) + } else if assignment.isSingleKey("cwd") { + buildersByName[serverName]?.cwd = parseTOMLStringValue(assignment.valueText) + } else if assignment.isSingleKey("enabled") { + buildersByName[serverName]?.enabled = parseTOMLBoolValue(assignment.valueText) + } else if assignment.isSingleKey("tool_timeout_sec") { + buildersByName[serverName]?.toolTimeoutSec = parseTOMLIntegerValue(assignment.valueText) + } + } else if let serverName = activeEnvName, + assignment.keyPath.count == 1, + let value = parseTOMLStringValue(assignment.valueText) + { + buildersByName[serverName]?.env[assignment.keyPath[0].normalized] = value + } + } + + return orderedNames.compactMap { name in + guard let builder = buildersByName[name], builder.hasRootHeader else { return nil } + return builder.configuration + } + } + /// Installs the RepoPrompt MCP server into Codex CLI (`~/.codex/config.toml`). /// /// Invoked from the UI when users opt-in to the integration. Ensures our MCP server exists and is @@ -658,6 +779,82 @@ enum CodexIntegrationConfiguration { return nil } + private static func parseTOMLBoolValue(_ valueText: Substring) -> Bool? { + let stripped = stripComment(fromValueText: String(valueText)).trimmingCharacters(in: .whitespacesAndNewlines) + if stripped == "true" { return true } + if stripped == "false" { return false } + return nil + } + + private static func parseTOMLStringArrayValue(_ valueText: Substring) -> [String]? { + let text = String(valueText) + var index = text.startIndex + skipWhitespace(in: text, from: &index) + guard index < text.endIndex, text[index] == "[" else { return nil } + index = text.index(after: index) + + var values: [String] = [] + var expectsValue = true + while index < text.endIndex { + skipWhitespace(in: text, from: &index) + guard index < text.endIndex else { return nil } + + if text[index] == "]" { + let trailing = text[text.index(after: index)...].trimmingCharacters(in: .whitespacesAndNewlines) + guard trailing.isEmpty || trailing.hasPrefix("#") else { return nil } + return values + } + + guard expectsValue else { return nil } + guard let parsed = parseTOMLStringLiteral(in: text, from: index) else { return nil } + values.append(parsed.value) + index = parsed.endIndex + skipWhitespace(in: text, from: &index) + guard index < text.endIndex else { return nil } + if text[index] == "," { + index = text.index(after: index) + expectsValue = true + } else { + expectsValue = false + } + } + return nil + } + + private static func parseTOMLStringLiteral( + in text: String, + from start: String.Index + ) -> (value: String, endIndex: String.Index)? { + guard start < text.endIndex else { return nil } + let quote = text[start] + guard quote == "\"" || quote == "'" else { return nil } + + var cursor = text.index(after: start) + var escaped = false + var inner = "" + while cursor < text.endIndex { + let ch = text[cursor] + if quote == "\"", escaped { + inner.append("\\") + inner.append(ch) + escaped = false + cursor = text.index(after: cursor) + continue + } + if quote == "\"", ch == "\\" { + escaped = true + cursor = text.index(after: cursor) + continue + } + if ch == quote { + return (quote == "\"" ? decodeDoubleQuotedTomlKey(inner) : inner, text.index(after: cursor)) + } + inner.append(ch) + cursor = text.index(after: cursor) + } + return nil + } + private static func parseTOMLIntegerValue(_ valueText: Substring) -> Int? { let stripped = stripComment(fromValueText: String(valueText)).trimmingCharacters(in: .whitespacesAndNewlines) guard !stripped.isEmpty, !stripped.hasPrefix("\""), !stripped.hasPrefix("'") else { return nil } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseRuntimeConfiguration.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseRuntimeConfiguration.swift new file mode 100644 index 000000000..2b754b940 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseRuntimeConfiguration.swift @@ -0,0 +1,309 @@ +import Foundation + +struct CodexComputerUseRuntimeConfiguration: Equatable { + enum Source: Equatable { + case explicitMCPServer(configPath: String) + case appManagedBundledPlugin(mcpConfigPath: String, manifestPath: String?, version: String?) + } + + struct Incomplete: Equatable { + let path: String + let message: String + } + + enum Resolution: Equatable { + case resolved(CodexComputerUseRuntimeConfiguration) + case incomplete(Incomplete) + case missingConfigFile(path: String) + case serverEntryMissing(path: String) + case unreadable(path: String, message: String) + } + + private struct PluginManifest: Decodable { + var name: String + var version: String? + } + + private struct MCPJSONDocument: Decodable { + var mcpServers: [String: MCPJSONServerDefinition] + } + + private struct MCPJSONServerDefinition: Decodable { + var command: String? + var args: [String]? + var cwd: String? + var env: [String: String]? + var enabled: Bool? + var toolTimeoutSec: Int? + + enum CodingKeys: String, CodingKey { + case command + case args + case cwd + case env + case enabled + case toolTimeoutSec + case toolTimeoutSnake = "tool_timeout_sec" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + command = try container.decodeIfPresent(String.self, forKey: .command) + args = try container.decodeIfPresent([String].self, forKey: .args) + cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + env = try container.decodeIfPresent([String: String].self, forKey: .env) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) + toolTimeoutSec = try container.decodeIfPresent(Int.self, forKey: .toolTimeoutSnake) + ?? container.decodeIfPresent(Int.self, forKey: .toolTimeoutSec) + } + } + + let serverName: String + let command: String + let args: [String] + let cwd: String? + let env: [String: String] + let enabled: Bool? + let toolTimeoutSec: Int? + let source: Source + + static let appManagedBundledPluginName = "computer-use@openai-bundled" + + static func resolve( + configURL: URL = CodexIntegrationConfiguration.configURL(), + codexDirectoryURL: URL = CodexIntegrationConfiguration.configDirectoryURL(), + fileManager: FileManager = .default + ) -> Resolution { + let configPath = configURL.path + var hasConfigFile = false + var declaresAppManagedPlugin = false + + if fileManager.fileExists(atPath: configPath) { + hasConfigFile = true + do { + let content = try String(contentsOf: configURL, encoding: .utf8) + declaresAppManagedPlugin = configDeclaresAppManagedPlugin(content) + if let explicit = CodexIntegrationConfiguration.mcpServerConfiguration( + named: CodexComputerUseConstants.mcpServerName, + fromConfigContent: content + ) { + return runtimeConfiguration( + from: explicit, + baseDirectory: configURL.deletingLastPathComponent(), + source: .explicitMCPServer(configPath: configPath), + definitionPath: configPath + ) + } + } catch { + if let appManaged = resolveAppManagedBundledPlugin( + codexDirectoryURL: codexDirectoryURL, + fileManager: fileManager + ) { + return appManaged + } + return .unreadable(path: configPath, message: error.localizedDescription) + } + } + + if let appManaged = resolveAppManagedBundledPlugin( + codexDirectoryURL: codexDirectoryURL, + fileManager: fileManager + ) { + return appManaged + } + + if declaresAppManagedPlugin { + return .incomplete(.init( + path: configPath, + message: "Codex config declares \(appManagedBundledPluginName), but RepoPrompt could not find a bundled Computer Use .mcp.json definition." + )) + } + + if hasConfigFile { + return .serverEntryMissing(path: configPath) + } + return .missingConfigFile(path: configPath) + } + + static func configDeclaresAppManagedPlugin(_ content: String) -> Bool { + content.range( + of: #"\[plugins\."?computer-use@openai-bundled"?\]"#, + options: [.regularExpression, .caseInsensitive] + ) != nil + } + + private static func resolveAppManagedBundledPlugin( + codexDirectoryURL: URL, + fileManager: FileManager + ) -> Resolution? { + var firstIncomplete: Resolution? + + func recordIncomplete(_ resolution: Resolution) { + if firstIncomplete == nil { + firstIncomplete = resolution + } + } + + for candidate in appManagedMCPJSONCandidates(codexDirectoryURL: codexDirectoryURL) { + guard fileManager.fileExists(atPath: candidate.mcpConfigURL.path) else { continue } + do { + let data = try Data(contentsOf: candidate.mcpConfigURL) + let document = try JSONDecoder().decode(MCPJSONDocument.self, from: data) + guard let definition = document.mcpServers.first(where: { + $0.key.caseInsensitiveCompare(CodexComputerUseConstants.mcpServerName) == .orderedSame + }) else { + recordIncomplete(.incomplete(.init( + path: candidate.mcpConfigURL.path, + message: "Bundled Computer Use .mcp.json does not define mcpServers.\(CodexComputerUseConstants.mcpServerName)." + ))) + continue + } + + let manifest = pluginManifest(at: candidate.manifestURL, fileManager: fileManager) + let resolution = runtimeConfiguration( + from: definition.value, + serverName: definition.key, + baseDirectory: candidate.mcpConfigURL.deletingLastPathComponent(), + source: .appManagedBundledPlugin( + mcpConfigPath: candidate.mcpConfigURL.path, + manifestPath: candidate.manifestURL.flatMap { fileManager.fileExists(atPath: $0.path) ? $0.path : nil }, + version: manifest?.version + ), + definitionPath: candidate.mcpConfigURL.path + ) + if case let .resolved(configuration) = resolution, + commandRequiresPathMaterialization(configuration.command), + !fileManager.fileExists(atPath: configuration.command) + { + recordIncomplete(.incomplete(.init( + path: candidate.mcpConfigURL.path, + message: "Bundled Computer Use helper command could not be materialized at \(configuration.command)." + ))) + continue + } + if case .incomplete = resolution { + recordIncomplete(resolution) + continue + } + return resolution + } catch { + recordIncomplete(.incomplete(.init( + path: candidate.mcpConfigURL.path, + message: "RepoPrompt could not read bundled Computer Use .mcp.json: \(error.localizedDescription)" + ))) + } + } + return firstIncomplete + } + + private static func runtimeConfiguration( + from configuration: CodexIntegrationConfiguration.ServerConfiguration, + baseDirectory: URL, + source: Source, + definitionPath: String + ) -> Resolution { + guard let command = nonEmpty(configuration.command) else { + return .incomplete(.init( + path: definitionPath, + message: "Computer Use MCP server configuration is missing a command." + )) + } + + let resolvedCWD = resolvedPath(configuration.cwd, relativeTo: baseDirectory, leaveBareCommandNames: false) + let commandBase = resolvedCWD.map { URL(fileURLWithPath: $0, isDirectory: true) } ?? baseDirectory + return .resolved(CodexComputerUseRuntimeConfiguration( + serverName: configuration.normalizedName, + command: resolvedPath(command, relativeTo: commandBase, leaveBareCommandNames: true) ?? command, + args: configuration.args ?? [], + cwd: resolvedCWD, + env: configuration.env, + enabled: configuration.enabled, + toolTimeoutSec: configuration.toolTimeoutSec, + source: source + )) + } + + private static func runtimeConfiguration( + from definition: MCPJSONServerDefinition, + serverName: String, + baseDirectory: URL, + source: Source, + definitionPath: String + ) -> Resolution { + guard let command = nonEmpty(definition.command) else { + return .incomplete(.init( + path: definitionPath, + message: "Bundled Computer Use .mcp.json server definition is missing a command." + )) + } + + let resolvedCWD = resolvedPath(definition.cwd, relativeTo: baseDirectory, leaveBareCommandNames: false) + let commandBase = resolvedCWD.map { URL(fileURLWithPath: $0, isDirectory: true) } ?? baseDirectory + return .resolved(CodexComputerUseRuntimeConfiguration( + serverName: serverName, + command: resolvedPath(command, relativeTo: commandBase, leaveBareCommandNames: true) ?? command, + args: definition.args ?? [], + cwd: resolvedCWD, + env: definition.env ?? [:], + enabled: definition.enabled, + toolTimeoutSec: definition.toolTimeoutSec, + source: source + )) + } + + private static func appManagedMCPJSONCandidates( + codexDirectoryURL: URL + ) -> [(mcpConfigURL: URL, manifestURL: URL?)] { + let tmpPlugin = codexDirectoryURL + .appendingPathComponent(".tmp/bundled-marketplaces/openai-bundled/plugins/computer-use", isDirectory: true) + let cachePlugin = codexDirectoryURL + .appendingPathComponent("plugins/cache/openai-bundled/computer-use", isDirectory: true) + let legacyPlugin = codexDirectoryURL + .appendingPathComponent("computer-use", isDirectory: true) + + return [tmpPlugin, cachePlugin, legacyPlugin].map { pluginDirectory in + ( + mcpConfigURL: pluginDirectory.appendingPathComponent(".mcp.json"), + manifestURL: pluginDirectory.appendingPathComponent(".codex-plugin/plugin.json") + ) + } + } + + private static func pluginManifest(at url: URL?, fileManager: FileManager) -> PluginManifest? { + guard let url, fileManager.fileExists(atPath: url.path) else { return nil } + guard let data = try? Data(contentsOf: url), + let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data), + manifest.name.caseInsensitiveCompare(CodexComputerUseConstants.mcpServerName) == .orderedSame + else { + return nil + } + return manifest + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private static func commandRequiresPathMaterialization(_ command: String) -> Bool { + command.hasPrefix("/") || command.hasPrefix(".") || command.contains("/") + } + + private static func resolvedPath( + _ value: String?, + relativeTo baseDirectory: URL, + leaveBareCommandNames: Bool + ) -> String? { + guard let raw = nonEmpty(value) else { return nil } + if raw.hasPrefix("~") { + return URL(fileURLWithPath: (raw as NSString).expandingTildeInPath).standardizedFileURL.path + } + if raw.hasPrefix("/") { + return URL(fileURLWithPath: raw).standardizedFileURL.path + } + if leaveBareCommandNames, !raw.contains("/") { + return raw + } + return baseDirectory.appendingPathComponent(raw).standardizedFileURL.path + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseStatus.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseStatus.swift new file mode 100644 index 000000000..803e77f48 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/Shared/CodexComputerUseStatus.swift @@ -0,0 +1,371 @@ +import Foundation + +enum CodexComputerUseConstants { + static let mcpServerName = "computer-use" +} + +enum CodexComputerUsePluginConfigurationStatus: Equatable { + case configured(serverName: String) + case appManagedPluginInstalled(path: String, version: String?) + case incomplete(path: String, message: String) + case missingConfigFile(path: String) + case serverEntryMissing(path: String) + case unreadable(path: String, message: String) + + var isConfigured: Bool { + switch self { + case .configured, .appManagedPluginInstalled: + true + case .incomplete, .missingConfigFile, .serverEntryMissing, .unreadable: + false + } + } + + var isAppManagedInstall: Bool { + if case .appManagedPluginInstalled = self { return true } + return false + } + + var title: String { + switch self { + case .configured: + "Configured" + case .appManagedPluginInstalled: + "Installed" + case .incomplete: + "Incomplete" + case .missingConfigFile: + "Config missing" + case .serverEntryMissing: + "Not configured" + case .unreadable: + "Config unreadable" + } + } + + var detail: String { + switch self { + case let .configured(serverName): + "Codex configuration includes \(serverName)." + case let .appManagedPluginInstalled(path, version): + "Codex Computer Use is installed by the Codex app at \(path)\(version.map { " (version \($0))" } ?? "")." + case let .incomplete(path, message): + "Codex Computer Use setup at \(path) is incomplete: \(message)" + case let .missingConfigFile(path): + "RepoPrompt could not find Codex config at \(path). Install or enable Computer Use in Codex, then refresh." + case let .serverEntryMissing(path): + "Codex config at \(path) does not contain a Computer Use plugin or MCP server entry." + case let .unreadable(path, message): + "RepoPrompt could not read Codex config at \(path): \(message)" + } + } +} + +enum CodexComputerUseLiveAvailability: Equatable { + case available(detail: String?) + case unavailable(reason: String) + case unknown(reason: String) + case unsupported(reason: String) + + var blocksReadiness: Bool { + if case .unavailable = self { return true } + return false + } + + var title: String { + switch self { + case .available: + "Available" + case .unavailable: + "Unavailable" + case .unknown: + "Unknown" + case .unsupported: + "Not verifiable" + } + } + + var detail: String { + switch self { + case let .available(detail): + detail ?? "Codex reported Computer Use tools are available." + case let .unavailable(reason), let .unknown(reason), let .unsupported(reason): + reason + } + } +} + +enum CodexComputerUsePermissionStatus: Equatable { + case granted + case notGranted + case unknown(reason: String) + + var isGranted: Bool { + if case .granted = self { return true } + return false + } + + var title: String { + switch self { + case .granted: + "Granted" + case .notGranted: + "Needs access" + case .unknown: + "Unknown" + } + } + + var detail: String { + switch self { + case .granted: + "Permission is granted." + case .notGranted: + "Permission has not been granted yet." + case let .unknown(reason): + reason + } + } +} + +enum CodexComputerUsePermissionRequestResult: Equatable { + case granted + case promptShownRefreshRequired + case deniedOrUnavailable + case failed(String) + + var userMessage: String { + switch self { + case .granted: + "Permission is already granted." + case .promptShownRefreshRequired: + "Permission prompt opened. Complete it, then refresh status. Screen Recording changes may require restarting RepoPrompt or Codex." + case .deniedOrUnavailable: + "Permission was not granted. Open System Settings, grant access, then refresh status." + case let .failed(message): + "Permission request failed: \(message)" + } + } +} + +enum CodexComputerUsePrerequisite: String, CaseIterable, Identifiable, Equatable { + case featureOptIn + case plugin + case liveAvailability + case screenRecording + case accessibility + + var id: String { + rawValue + } + + var displayName: String { + switch self { + case .featureOptIn: "RepoPrompt Computer Use" + case .plugin: "Computer Use MCP configuration" + case .liveAvailability: "Computer Use live availability" + case .screenRecording: "Screen Recording" + case .accessibility: "Accessibility" + } + } + + var shortAction: String { + switch self { + case .featureOptIn: + "enable Computer Use in RepoPrompt" + case .plugin: + "configure the Computer Use MCP server in Codex" + case .liveAvailability: + "confirm Codex Computer Use tools are available" + case .screenRecording: + "verify Codex Computer Use Screen Recording permission" + case .accessibility: + "verify Codex Computer Use Accessibility permission" + } + } +} + +struct CodexComputerUsePrerequisiteSnapshot: Equatable { + var pluginConfiguration: CodexComputerUsePluginConfigurationStatus + var liveAvailability: CodexComputerUseLiveAvailability + var screenRecording: CodexComputerUsePermissionStatus + var accessibility: CodexComputerUsePermissionStatus + + init( + pluginConfiguration: CodexComputerUsePluginConfigurationStatus, + liveAvailability: CodexComputerUseLiveAvailability = .unsupported(reason: CodexComputerUseStatus.defaultLiveAvailabilityUnsupportedReason), + screenRecording: CodexComputerUsePermissionStatus, + accessibility: CodexComputerUsePermissionStatus + ) { + self.pluginConfiguration = pluginConfiguration + self.liveAvailability = liveAvailability + self.screenRecording = screenRecording + self.accessibility = accessibility + } + + init( + pluginInstalled: Bool, + screenRecordingGranted: Bool, + accessibilityGranted: Bool, + liveAvailability: CodexComputerUseLiveAvailability = .unsupported(reason: CodexComputerUseStatus.defaultLiveAvailabilityUnsupportedReason) + ) { + self.init( + pluginConfiguration: pluginInstalled + ? .configured(serverName: CodexComputerUseConstants.mcpServerName) + : .serverEntryMissing(path: CodexComputerUseStatus.defaultCodexConfigPath), + liveAvailability: liveAvailability, + screenRecording: screenRecordingGranted ? .granted : .notGranted, + accessibility: accessibilityGranted ? .granted : .notGranted + ) + } + + var pluginInstalled: Bool { + pluginConfiguration.isConfigured + } + + var screenRecordingGranted: Bool { + screenRecording.isGranted + } + + var accessibilityGranted: Bool { + accessibility.isGranted + } + + static let ready = CodexComputerUsePrerequisiteSnapshot( + pluginInstalled: true, + screenRecordingGranted: true, + accessibilityGranted: true + ) + + static let missingAll = CodexComputerUsePrerequisiteSnapshot( + pluginInstalled: false, + screenRecordingGranted: false, + accessibilityGranted: false + ) +} + +struct CodexComputerUseStatus: Equatable { + static let defaultCodexConfigPath = "~/.codex/config.toml" + static let defaultLiveAvailabilityUnsupportedReason = "RepoPrompt cannot verify live Codex Computer Use tool availability in this build; static Codex config detection is used." + + let optInEnabled: Bool + let prerequisites: CodexComputerUsePrerequisiteSnapshot + let lastRefreshedAt: Date? + + init( + optInEnabled: Bool, + prerequisites: CodexComputerUsePrerequisiteSnapshot, + lastRefreshedAt: Date? = nil + ) { + self.optInEnabled = optInEnabled + self.prerequisites = prerequisites + self.lastRefreshedAt = lastRefreshedAt + } + + var pluginConfiguration: CodexComputerUsePluginConfigurationStatus { + prerequisites.pluginConfiguration + } + + var liveAvailability: CodexComputerUseLiveAvailability { + prerequisites.liveAvailability + } + + var screenRecording: CodexComputerUsePermissionStatus { + prerequisites.screenRecording + } + + var accessibility: CodexComputerUsePermissionStatus { + prerequisites.accessibility + } + + var usesCodexManagedMacPermissions: Bool { + pluginConfiguration.isAppManagedInstall + } + + var screenRecordingSatisfied: Bool { + usesCodexManagedMacPermissions || screenRecording.isGranted + } + + var accessibilitySatisfied: Bool { + usesCodexManagedMacPermissions || accessibility.isGranted + } + + var isReady: Bool { + optInEnabled + && pluginConfiguration.isConfigured + && !liveAvailability.blocksReadiness + && screenRecordingSatisfied + && accessibilitySatisfied + } + + var missingRequirements: [CodexComputerUsePrerequisite] { + var values: [CodexComputerUsePrerequisite] = [] + if !optInEnabled { values.append(.featureOptIn) } + if !pluginConfiguration.isConfigured { values.append(.plugin) } + if liveAvailability.blocksReadiness { values.append(.liveAvailability) } + if !screenRecordingSatisfied { values.append(.screenRecording) } + if !accessibilitySatisfied { values.append(.accessibility) } + return values + } + + var primaryUnavailableMessage: String { + guard !isReady else { return "Codex computer-use is ready." } + let missing = missingRequirements + if missing == [.featureOptIn] { + return "Enable Computer Use in Settings → Agent Mode → Computer Use before starting /computer-use." + } + let details = missing.map(\.shortAction).joined(separator: "; ") + return "Codex computer-use setup is incomplete: \(details). Open Settings → Agent Mode → Computer Use, complete the setup, then refresh status." + } + + var statusTitle: String { + if isReady { return "Ready" } + if !optInEnabled { return "Not enabled" } + return "Setup incomplete" + } + + var statusDetail: String { + if isReady { + return "RepoPrompt can expose /computer-use for explicit Codex turns. Codex will still ask for app access and sensitive-action approvals." + } + return primaryUnavailableMessage + } +} + +struct CodexComputerUseAvailability: Equatable { + let featureOptIn: Bool + let prerequisites: CodexComputerUsePrerequisiteSnapshot + let status: CodexComputerUseStatus + + init(featureOptIn: Bool, prerequisites: CodexComputerUsePrerequisiteSnapshot) { + self.featureOptIn = featureOptIn + self.prerequisites = prerequisites + status = CodexComputerUseStatus(optInEnabled: featureOptIn, prerequisites: prerequisites) + } + + init(status: CodexComputerUseStatus) { + featureOptIn = status.optInEnabled + prerequisites = status.prerequisites + self.status = status + } + + var isReady: Bool { + status.isReady + } + + var missingPrerequisites: [CodexComputerUsePrerequisite] { + status.missingRequirements + } + + var primaryUnavailableMessage: String { + status.primaryUnavailableMessage + } + + var statusTitle: String { + status.statusTitle + } + + var statusDetail: String { + status.statusDetail + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift index 0a987c754..6633b8eab 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift @@ -792,6 +792,50 @@ private enum AppSettingsMCPRegistry { read: { .bool($0.codexGoalSupportEnabled()) }, write: { try $0.setCodexGoalSupportEnabled(requiredBool(from: $1)) } ), + boolSetting( + key: "agent_mode.codex_computer_use_enabled", + group: "agent_mode", + label: "Codex Computer Use", + description: "Opt-in toggle for /computer-use. This can be enabled before setup is complete; the workflow is exposed only when the Codex Computer Use plugin is installed or configured, live availability is not explicitly unavailable, and macOS Screen Recording and Accessibility permissions are granted.", + read: { .bool($0.codexComputerUseEnabled()) }, + write: { try $0.setCodexComputerUseEnabled(requiredBool(from: $1)) } + ), + boolSetting( + key: "agent_mode.codex_raw_event_logging_enabled", + group: "agent_mode", + label: "Codex Raw Event Logging", + description: "Opt-in toggle for Codex native raw event JSONL capture. Disabled by default. Writes UserDefaults key 'codexRawEventLoggingEnabled'.", + read: { .bool($0.codexRawEventLoggingEnabled()) }, + write: { try $0.setCodexRawEventLoggingEnabled(requiredBool(from: $1)) } + ), + rawTextSetting( + key: "agent_mode.codex_raw_event_log_file_path", + group: "agent_mode", + label: "Codex Raw Event Log Directory", + description: "Directory override for Codex native raw event JSONL files. Empty string clears the override; enabled logging then writes under the workspace .codexlogs directory or a temp fallback. Writes UserDefaults key 'codexRawEventLogFilePath'.", + maxLength: debugDefaultsStringMaxLength, + allowEmpty: true, + read: { .string($0.codexRawEventLogFilePath()) }, + write: { try $0.setCodexRawEventLogFilePath(requiredString(from: $1)) } + ), + boolSetting( + key: "agent_mode.codex_app_server_diagnostics_enabled", + group: "agent_mode", + label: "Codex App Server Diagnostics", + description: "Opt-in toggle for sanitized Codex app-server process, JSON-RPC, stderr, and runtime configuration diagnostics. Disabled by default. Writes UserDefaults key 'codexAppServerDiagnosticsEnabled'.", + read: { .bool($0.codexAppServerDiagnosticsEnabled()) }, + write: { try $0.setCodexAppServerDiagnosticsEnabled(requiredBool(from: $1)) } + ), + rawTextSetting( + key: "agent_mode.codex_app_server_diagnostics_log_file_path", + group: "agent_mode", + label: "Codex App Server Diagnostics Directory", + description: "Directory override for sanitized Codex app-server diagnostics JSONL files. Empty string clears the override; enabled logging then writes to a temp diagnostics directory. Writes UserDefaults key 'codexAppServerDiagnosticsLogFilePath'.", + maxLength: debugDefaultsStringMaxLength, + allowEmpty: true, + read: { .string($0.codexAppServerDiagnosticsLogFilePath()) }, + write: { try $0.setCodexAppServerDiagnosticsLogFilePath(requiredString(from: $1)) } + ), // File-system / ignore preferences. Local .repo_ignore file content remains // repository content; this group exposes app-wide scalar behavior only. diff --git a/Tests/RepoPromptTests/AI/CodexAppServerDiagnosticsTests.swift b/Tests/RepoPromptTests/AI/CodexAppServerDiagnosticsTests.swift new file mode 100644 index 000000000..df83c5169 --- /dev/null +++ b/Tests/RepoPromptTests/AI/CodexAppServerDiagnosticsTests.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import RepoPrompt +import XCTest + +final class CodexAppServerDiagnosticsTests: XCTestCase { + func testTimeoutFailureMessageIncludesMethodAndRequestID() { + let message = CodexAppServerClient.timeoutFailureMessage( + method: "thread/start", + requestID: "42", + timeout: 120 + ) + + XCTAssertTrue(message.contains("120s"), message) + XCTAssertTrue(message.contains("method: thread/start"), message) + XCTAssertTrue(message.contains("request id: 42"), message) + XCTAssertTrue(CodexAppServerClient.isTimeoutError(CodexAppServerClient.ClientError.requestFailed(message))) + + let whitespaceMessage = CodexAppServerClient.timeoutFailureMessage( + method: " thread/resume\n", + requestID: "43", + timeout: 120 + ) + XCTAssertTrue(whitespaceMessage.contains("method: thread/resume"), whitespaceMessage) + XCTAssertFalse(whitespaceMessage.contains("method: thread/resume"), whitespaceMessage) + } + + func testTimeoutFailureMessageHandlesUnknownMethod() { + let message = CodexAppServerClient.timeoutFailureMessage( + method: nil, + requestID: "abc", + timeout: 1.5 + ) + + XCTAssertTrue(message.contains("1.5s"), message) + XCTAssertTrue(message.contains("method: "), message) + XCTAssertTrue(message.contains("request id: abc"), message) + } + + func testJSONRPCPayloadSummaryDoesNotCapturePromptContent() throws { + let summary = CodexAppServerDiagnostics.jsonRPCPayloadSummary([ + "id": 1, + "method": "thread/start", + "params": [ + "baseInstructions": "private instructions", + "input": [["text": "secret prompt"]] + ] + ]) + + XCTAssertEqual(summary["method"] as? String, "thread/start") + let paramsSummary = try XCTUnwrap(summary["params"] as? [String: Any]) + XCTAssertEqual(paramsSummary["type"] as? String, "object") + XCTAssertEqual(paramsSummary["keyCount"] as? Int, 2) + XCTAssertFalse(String(describing: summary).contains("private instructions")) + XCTAssertFalse(String(describing: summary).contains("secret prompt")) + } + + func testDiagnosticsSanitizerRedactsSensitiveKeysAndKeepsShape() throws { + let sanitized = try XCTUnwrap(CodexAppServerDiagnostics.sanitizedJSONObject([ + "method": "thread/start", + "accessToken": "secret-token", + "nested": [ + "api_key": "secret-key", + "cwd": "/tmp/workspace" + ] + ]) as? [String: Any]) + + XCTAssertEqual(sanitized["method"] as? String, "thread/start") + XCTAssertEqual(sanitized["accessToken"] as? String, "") + let nested = try XCTUnwrap(sanitized["nested"] as? [String: Any]) + XCTAssertEqual(nested["api_key"] as? String, "") + XCTAssertEqual(nested["cwd"] as? String, "/tmp/workspace") + } +} diff --git a/Tests/RepoPromptTests/AgentMode/Codex/AgentModeComputerUseSettingsViewModelTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/AgentModeComputerUseSettingsViewModelTests.swift new file mode 100644 index 000000000..f644a172a --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/Codex/AgentModeComputerUseSettingsViewModelTests.swift @@ -0,0 +1,129 @@ +import AppKit +import Foundation +@testable import RepoPrompt +import XCTest + +@MainActor +final class AgentModeComputerUseSettingsViewModelTests: XCTestCase { + override func tearDown() { + CodexComputerUseWorkflow.setEnabledForTesting(nil) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(nil) + super.tearDown() + } + + func testStatusUsesEffectiveOptInOverrideForSettingsConsistency() throws { + CodexComputerUseWorkflow.setEnabledForTesting(true) + let store = try makeStore() + store.setCodexComputerUseEnabled(false) + + let viewModel = AgentModeComputerUseSettingsViewModel( + globalSettings: store, + statusService: .testing(), + pasteboard: makePasteboard() + ) + + XCTAssertFalse(viewModel.optInEnabled) + XCTAssertTrue(viewModel.status.optInEnabled) + XCTAssertTrue(viewModel.status.isReady) + } + + func testSetOptInEnabledWritesInjectedSettingsStore() throws { + CodexComputerUseWorkflow.setEnabledForTesting(nil) + let store = try makeStore() + store.setCodexComputerUseEnabled(false) + let viewModel = AgentModeComputerUseSettingsViewModel( + globalSettings: store, + statusService: .testing(), + pasteboard: makePasteboard() + ) + + viewModel.setOptInEnabled(true) + + XCTAssertTrue(store.codexComputerUseEnabled()) + XCTAssertTrue(viewModel.optInEnabled) + XCTAssertTrue(viewModel.status.optInEnabled) + XCTAssertTrue(viewModel.status.isReady) + } + + func testPermissionRequestRefreshesStatusAndMessage() throws { + CodexComputerUseWorkflow.setEnabledForTesting(true) + let store = try makeStore() + store.setCodexComputerUseEnabled(true) + var screenRecordingStatus = CodexComputerUsePermissionStatus.notGranted + var requestedScreenRecording = false + let service = CodexComputerUseStatusService.testing( + permissionClient: .init( + screenRecordingStatus: { screenRecordingStatus }, + accessibilityStatus: { .granted }, + requestScreenRecording: { + requestedScreenRecording = true + screenRecordingStatus = .granted + return .granted + }, + requestAccessibility: { .granted } + ) + ) + let viewModel = AgentModeComputerUseSettingsViewModel( + globalSettings: store, + statusService: service, + pasteboard: makePasteboard() + ) + + XCTAssertEqual(viewModel.status.screenRecording, .notGranted) + + viewModel.requestScreenRecordingAccess() + + XCTAssertTrue(requestedScreenRecording) + XCTAssertEqual(viewModel.status.screenRecording, .granted) + XCTAssertEqual(viewModel.lastActionMessage, CodexComputerUsePermissionRequestResult.granted.userMessage) + } + + func testOpenAndCopyActionsUseInjectedDependencies() throws { + CodexComputerUseWorkflow.setEnabledForTesting(true) + let store = try makeStore() + store.setCodexComputerUseEnabled(true) + var openedURLs: [URL] = [] + let pasteboard = makePasteboard() + let viewModel = AgentModeComputerUseSettingsViewModel( + globalSettings: store, + statusService: .testing(), + openURL: { openedURLs.append($0) }, + pasteboard: pasteboard + ) + + viewModel.openAccessibilitySettings() + viewModel.openScreenRecordingSettings() + viewModel.openCodexComputerUseGuide() + viewModel.copyManualSetupInstructions(for: .screenRecording) + + XCTAssertEqual(openedURLs.map(\.absoluteString), [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + "https://developers.openai.com/codex/app/computer-use" + ]) + XCTAssertTrue(pasteboard.string(forType: .string)?.contains("Screen Recording") == true) + XCTAssertEqual(viewModel.lastActionMessage, "Copied setup instructions to the clipboard.") + } + + private func makeStore() throws -> GlobalSettingsStore { + let temp = FileManager.default.temporaryDirectory.appendingPathComponent( + "AgentModeComputerUseSettingsViewModelTests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let suiteName = "AgentModeComputerUseSettingsViewModelTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return GlobalSettingsStore( + defaults: defaults, + fileStore: GlobalSettingsFileStore(fileURL: fileURL) + ) + } + + private func makePasteboard() -> NSPasteboard { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("AgentModeComputerUseSettingsViewModelTests.\(UUID().uuidString)")) + pasteboard.clearContents() + return pasteboard + } +} diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseStatusTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseStatusTests.swift new file mode 100644 index 000000000..986e21e9b --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseStatusTests.swift @@ -0,0 +1,352 @@ +import Foundation +@testable import RepoPrompt +import XCTest + +final class CodexComputerUseStatusTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + try super.tearDownWithError() + } + + func testStatusReadinessAllowsUnsupportedLiveAvailabilityButBlocksUnavailable() { + let ready = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: .configured(serverName: "computer-use"), + liveAvailability: .unsupported(reason: "No catalog"), + screenRecording: .granted, + accessibility: .granted + ) + ) + XCTAssertTrue(ready.isReady) + XCTAssertEqual(ready.missingRequirements, []) + + let unavailable = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: .configured(serverName: "computer-use"), + liveAvailability: .unavailable(reason: "Tool catalog missing computer-use"), + screenRecording: .granted, + accessibility: .granted + ) + ) + XCTAssertFalse(unavailable.isReady) + XCTAssertEqual(unavailable.missingRequirements, [.liveAvailability]) + } + + func testMissingPrerequisitesAreReportedSeparately() { + let status = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: .serverEntryMissing(path: "/tmp/config.toml"), + liveAvailability: .unknown(reason: "No probe yet"), + screenRecording: .notGranted, + accessibility: .unknown(reason: "AX unavailable") + ) + ) + + XCTAssertFalse(status.isReady) + XCTAssertEqual(status.missingRequirements, [.plugin, .screenRecording, .accessibility]) + XCTAssertTrue(status.primaryUnavailableMessage.contains("configure")) + XCTAssertTrue(status.primaryUnavailableMessage.contains("Screen Recording")) + XCTAssertTrue(status.primaryUnavailableMessage.contains("Accessibility")) + } + + func testAppManagedPluginStatusSatisfiesConfigurationGate() { + let status = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: .appManagedPluginInstalled( + path: "/Users/example/.codex/computer-use/config.json", + version: "1.0.799" + ), + liveAvailability: .unsupported(reason: "No live catalog"), + screenRecording: .notGranted, + accessibility: .notGranted + ) + ) + + XCTAssertTrue(status.pluginConfiguration.isConfigured) + XCTAssertTrue(status.usesCodexManagedMacPermissions) + XCTAssertTrue(status.screenRecordingSatisfied) + XCTAssertTrue(status.accessibilitySatisfied) + XCTAssertTrue(status.isReady) + XCTAssertTrue(status.pluginConfiguration.detail.contains("Codex Computer Use is installed")) + } + + func testConfigPluginStanzaDeclaresComputerUsePlugin() { + XCTAssertTrue(CodexComputerUseStatusService.test_configDeclaresComputerUsePlugin(""" + [plugins."computer-use@openai-bundled"] + """)) + XCTAssertTrue(CodexComputerUseStatusService.test_configDeclaresComputerUsePlugin(""" + [plugins.computer-use@openai-bundled] + """)) + XCTAssertFalse(CodexComputerUseStatusService.test_configDeclaresComputerUsePlugin(""" + [plugins."browser@openai-bundled"] + """)) + } + + func testServiceUsesInjectedDependenciesAndRequestActions() { + var requestedScreenRecording = false + var requestedAccessibility = false + let service = CodexComputerUseStatusService.testing( + configProbe: { .configured(serverName: "Computer-Use") }, + permissionClient: .init( + screenRecordingStatus: { .notGranted }, + accessibilityStatus: { .granted }, + requestScreenRecording: { + requestedScreenRecording = true + return .promptShownRefreshRequired + }, + requestAccessibility: { + requestedAccessibility = true + return .granted + } + ), + liveAvailabilityProbe: { .unknown(reason: "No live catalog") }, + now: { Date(timeIntervalSince1970: 42) } + ) + + let status = service.currentStatus(optInEnabled: true) + XCTAssertFalse(status.isReady) + XCTAssertEqual(status.pluginConfiguration, .configured(serverName: "Computer-Use")) + XCTAssertEqual(status.liveAvailability, .unknown(reason: "No live catalog")) + XCTAssertEqual(status.lastRefreshedAt, Date(timeIntervalSince1970: 42)) + + XCTAssertEqual(service.requestScreenRecordingAccess(), .promptShownRefreshRequired) + XCTAssertTrue(requestedScreenRecording) + XCTAssertEqual(service.requestAccessibilityAccess(), .granted) + XCTAssertTrue(requestedAccessibility) + } + + func testCodexIntegrationConfigParserDetectsComputerUseEntryFromConfigContent() { + let entries = CodexIntegrationConfiguration.mcpServerEntries(from: """ + [mcp_servers.RepoPromptCE] + command = "rpce-cli-debug" + + [mcp_servers."computer-use"] + command = "computer-use" + + [mcp_servers."computer-use".env] + FOO = "bar" + """) + XCTAssertTrue(entries.contains { $0.normalizedName.caseInsensitiveCompare("computer-use") == .orderedSame }) + } + + func testRuntimeResolverResolvesExplicitMCPServerAndRelativePaths() throws { + let codexDirectory = try makeTemporaryCodexDirectory() + let configURL = codexDirectory.appendingPathComponent("config.toml") + try """ + [mcp_servers."computer-use"] + command = "./bin/computer-use" + args = ["mcp"] + cwd = "runtime" + tool_timeout_sec = 123 + """.write(to: configURL, atomically: true, encoding: .utf8) + + let resolution = CodexComputerUseRuntimeConfiguration.resolve( + configURL: configURL, + codexDirectoryURL: codexDirectory + ) + guard case let .resolved(configuration) = resolution else { + return XCTFail("Expected resolved explicit MCP server, got \(resolution)") + } + + let expectedCWD = codexDirectory.appendingPathComponent("runtime").standardizedFileURL.path + XCTAssertEqual(configuration.serverName, "computer-use") + XCTAssertEqual(configuration.command, URL(fileURLWithPath: expectedCWD).appendingPathComponent("bin/computer-use").standardizedFileURL.path) + XCTAssertEqual(configuration.args, ["mcp"]) + XCTAssertEqual(configuration.cwd, expectedCWD) + XCTAssertEqual(configuration.toolTimeoutSec, 123) + XCTAssertEqual( + CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory), + .configured(serverName: "computer-use") + ) + } + + func testRuntimeResolverResolvesAppManagedBundledPluginAndStatusSkipsRepoPromptPermissions() throws { + let codexDirectory = try makeTemporaryCodexDirectory() + let configURL = codexDirectory.appendingPathComponent("config.toml") + try """ + [plugins."computer-use@openai-bundled"] + enabled = true + """.write(to: configURL, atomically: true, encoding: .utf8) + let pluginDirectory = codexDirectory.appendingPathComponent( + "plugins/cache/openai-bundled/computer-use", + isDirectory: true + ) + let helperURL = pluginDirectory.appendingPathComponent("Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient") + try FileManager.default.createDirectory(at: helperURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "#!/bin/sh\n".write(to: helperURL, atomically: true, encoding: .utf8) + try """ + { + "mcpServers": { + "computer-use": { + "command": "./Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient", + "args": ["mcp"], + "cwd": ".", + "env": { "SKY_CUA_SERVICE_PATH": "./service" }, + "tool_timeout_sec": 456 + } + } + } + """.write(to: pluginDirectory.appendingPathComponent(".mcp.json"), atomically: true, encoding: .utf8) + let manifestURL = pluginDirectory.appendingPathComponent(".codex-plugin/plugin.json") + try FileManager.default.createDirectory(at: manifestURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try """ + { "name": "computer-use", "version": "1.2.3" } + """.write(to: manifestURL, atomically: true, encoding: .utf8) + + let resolution = CodexComputerUseRuntimeConfiguration.resolve( + configURL: configURL, + codexDirectoryURL: codexDirectory + ) + guard case let .resolved(configuration) = resolution else { + return XCTFail("Expected resolved app-managed plugin, got \(resolution)") + } + + XCTAssertEqual(configuration.command, helperURL.standardizedFileURL.path) + XCTAssertEqual(configuration.args, ["mcp"]) + XCTAssertEqual(configuration.cwd, pluginDirectory.standardizedFileURL.path) + XCTAssertEqual(configuration.env, ["SKY_CUA_SERVICE_PATH": "./service"]) + XCTAssertEqual(configuration.toolTimeoutSec, 456) + XCTAssertEqual( + CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory), + .appManagedPluginInstalled(path: pluginDirectory.appendingPathComponent(".mcp.json").path, version: "1.2.3") + ) + + let status = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory), + screenRecording: .notGranted, + accessibility: .notGranted + ) + ) + XCTAssertTrue(status.usesCodexManagedMacPermissions) + XCTAssertTrue(status.isReady) + } + + func testRuntimeResolverSkipsBrokenTmpCandidateWhenCachePluginIsValid() throws { + let codexDirectory = try makeTemporaryCodexDirectory() + let configURL = codexDirectory.appendingPathComponent("config.toml") + try """ + [plugins."computer-use@openai-bundled"] + enabled = true + """.write(to: configURL, atomically: true, encoding: .utf8) + + let tmpPluginDirectory = codexDirectory.appendingPathComponent( + ".tmp/bundled-marketplaces/openai-bundled/plugins/computer-use", + isDirectory: true + ) + try FileManager.default.createDirectory(at: tmpPluginDirectory, withIntermediateDirectories: true) + try """ + { "mcpServers": { "browser": { "command": "browser" } } } + """.write(to: tmpPluginDirectory.appendingPathComponent(".mcp.json"), atomically: true, encoding: .utf8) + + let cachePluginDirectory = codexDirectory.appendingPathComponent( + "plugins/cache/openai-bundled/computer-use", + isDirectory: true + ) + let helperURL = cachePluginDirectory.appendingPathComponent("Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient") + try FileManager.default.createDirectory(at: helperURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "#!/bin/sh\n".write(to: helperURL, atomically: true, encoding: .utf8) + try """ + { + "mcpServers": { + "computer-use": { + "command": "./Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient", + "args": ["mcp"], + "cwd": "." + } + } + } + """.write(to: cachePluginDirectory.appendingPathComponent(".mcp.json"), atomically: true, encoding: .utf8) + + let resolution = CodexComputerUseRuntimeConfiguration.resolve( + configURL: configURL, + codexDirectoryURL: codexDirectory + ) + guard case let .resolved(configuration) = resolution else { + return XCTFail("Expected valid cache plugin to win after broken tmp candidate, got \(resolution)") + } + XCTAssertEqual(configuration.command, helperURL.standardizedFileURL.path) + XCTAssertEqual( + CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory), + .appManagedPluginInstalled(path: cachePluginDirectory.appendingPathComponent(".mcp.json").path, version: nil) + ) + } + + func testRuntimeResolverTreatsPluginMarkerWithoutMCPDefinitionAsIncomplete() throws { + let codexDirectory = try makeTemporaryCodexDirectory() + let configURL = codexDirectory.appendingPathComponent("config.toml") + try """ + [plugins."computer-use@openai-bundled"] + enabled = true + """.write(to: configURL, atomically: true, encoding: .utf8) + + let pluginStatus = CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory) + guard case let .incomplete(path, message) = pluginStatus else { + return XCTFail("Expected incomplete plugin status, got \(pluginStatus)") + } + XCTAssertEqual(path, configURL.path) + XCTAssertTrue(message.contains(".mcp.json")) + + let status = CodexComputerUseStatus( + optInEnabled: true, + prerequisites: .init( + pluginConfiguration: pluginStatus, + screenRecording: .notGranted, + accessibility: .notGranted + ) + ) + XCTAssertFalse(status.isReady) + XCTAssertEqual(status.missingRequirements, [.plugin, .screenRecording, .accessibility]) + } + + func testRuntimeResolverTreatsMissingAppManagedHelperAsIncomplete() throws { + let codexDirectory = try makeTemporaryCodexDirectory() + let configURL = codexDirectory.appendingPathComponent("config.toml") + try """ + [plugins.computer-use@openai-bundled] + enabled = true + """.write(to: configURL, atomically: true, encoding: .utf8) + let pluginDirectory = codexDirectory.appendingPathComponent( + "plugins/cache/openai-bundled/computer-use", + isDirectory: true + ) + try FileManager.default.createDirectory(at: pluginDirectory, withIntermediateDirectories: true) + try """ + { + "mcpServers": { + "computer-use": { + "command": "./Missing.app/Contents/MacOS/SkyComputerUseClient", + "args": ["mcp"], + "cwd": "." + } + } + } + """.write(to: pluginDirectory.appendingPathComponent(".mcp.json"), atomically: true, encoding: .utf8) + + let pluginStatus = CodexComputerUseStatusService.configProbe(configURL: configURL, codexDirectoryURL: codexDirectory) + guard case let .incomplete(path, message) = pluginStatus else { + return XCTFail("Expected incomplete plugin status, got \(pluginStatus)") + } + XCTAssertEqual(path, pluginDirectory.appendingPathComponent(".mcp.json").path) + XCTAssertTrue(message.contains("could not be materialized")) + } + + private func makeTemporaryCodexDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexComputerUseStatusTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory.deletingLastPathComponent()) + return directory + } +} diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseWorkflowTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseWorkflowTests.swift new file mode 100644 index 000000000..2463c0155 --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexComputerUseWorkflowTests.swift @@ -0,0 +1,314 @@ +import Foundation +import XCTest +@_spi(TestSupport) @testable import RepoPrompt + +@MainActor +final class CodexComputerUseWorkflowTests: XCTestCase { + override func tearDown() { + CodexComputerUseWorkflow.setEnabledForTesting(nil) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(nil) + super.tearDown() + } + + func testAvailabilityRequiresOptInPluginAndMacPermissions() { + let ready = CodexComputerUseAvailability( + featureOptIn: true, + prerequisites: .ready + ) + XCTAssertTrue(ready.isReady) + XCTAssertEqual(ready.missingPrerequisites, []) + + let disabled = CodexComputerUseAvailability( + featureOptIn: false, + prerequisites: .ready + ) + XCTAssertFalse(disabled.isReady) + XCTAssertEqual(disabled.missingPrerequisites, [.featureOptIn]) + XCTAssertTrue(disabled.primaryUnavailableMessage.contains("Enable Computer Use")) + + let missingPlugin = CodexComputerUseAvailability( + featureOptIn: true, + prerequisites: .init( + pluginInstalled: false, + screenRecordingGranted: true, + accessibilityGranted: true + ) + ) + XCTAssertFalse(missingPlugin.isReady) + XCTAssertEqual(missingPlugin.missingPrerequisites, [.plugin]) + XCTAssertTrue(missingPlugin.primaryUnavailableMessage.contains("MCP server")) + + let missingPermissions = CodexComputerUseAvailability( + featureOptIn: true, + prerequisites: .init( + pluginInstalled: true, + screenRecordingGranted: false, + accessibilityGranted: false + ) + ) + XCTAssertFalse(missingPermissions.isReady) + XCTAssertEqual(missingPermissions.missingPrerequisites, [.screenRecording, .accessibility]) + XCTAssertTrue(missingPermissions.primaryUnavailableMessage.contains("Screen Recording")) + XCTAssertTrue(missingPermissions.primaryUnavailableMessage.contains("Accessibility")) + } + + func testSlashCommandHiddenAndActionableWhenPrerequisitesMissing() { + CodexComputerUseWorkflow.setEnabledForTesting(true) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(.init( + pluginInstalled: false, + screenRecordingGranted: true, + accessibilityGranted: true + )) + let viewModel = makeViewModel() + let session = preparedCodexSession(in: viewModel) + + let message = viewModel.test_codexCoordinator.nativeSlashCommandAvailabilityMessage( + .computerUse, + argumentsText: "", + session: session + ) + XCTAssertNotNil(message) + XCTAssertTrue(message?.contains("MCP server") == true) + XCTAssertTrue(message?.contains("Settings → Agent Mode → Computer Use") == true) + + let suggestions = viewModel.test_codexCoordinator.nativeSlashCommandSuggestions( + for: session, + query: "computer", + limit: 10 + ) + XCTAssertFalse(suggestions.contains { $0.displayName == "/computer-use" }) + } + + func testSlashCommandAvailableWhenPrerequisitesSatisfied() { + CodexComputerUseWorkflow.setEnabledForTesting(true) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(.ready) + let viewModel = makeViewModel() + let session = preparedCodexSession(in: viewModel) + + XCTAssertNil(viewModel.test_codexCoordinator.nativeSlashCommandAvailabilityMessage( + .computerUse, + argumentsText: "", + session: session + )) + + let suggestions = viewModel.test_codexCoordinator.nativeSlashCommandSuggestions( + for: session, + query: "computer", + limit: 10 + ) + XCTAssertTrue(suggestions.contains { $0.displayName == "/computer-use" }) + } + + func testPendingComputerUseActivationReconnectsControllerWithEnabledFlag() async { + CodexComputerUseWorkflow.setEnabledForTesting(true) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(.ready) + let existingController = ComputerUseFakeCodexController() + var requestedComputerUseFlags: [Bool] = [] + let replacementController = ComputerUseFakeCodexController() + let viewModel = makeViewModel { _, _, _, _, _, _, computerUseEnabled in + requestedComputerUseFlags.append(computerUseEnabled) + return replacementController + } + let session = preparedCodexSession(in: viewModel, controller: existingController) + session.pendingCodexComputerUseActivation = .init(id: UUID(), createdAt: Date()) + session.codexControllerComputerUseEnabled = false + + await viewModel.test_codexCoordinator.ensureCodexNativeSession( + session: session, + policyAlreadyInstalled: true, + allowMissingRolloutFallback: false, + allowResumeTimeoutFallback: false + ) + + XCTAssertEqual(requestedComputerUseFlags, [true]) + XCTAssertTrue(session.codexControllerComputerUseEnabled) + XCTAssertTrue(replacementController.startOrResumeCallCount > 0) + } + + func testComputerUseActivationSettlesAfterOneTurnAndReconnectsDisabled() async { + CodexComputerUseWorkflow.setEnabledForTesting(true) + CodexComputerUseWorkflow.setPrerequisiteSnapshotForTesting(.ready) + let existingController = ComputerUseFakeCodexController() + var requestedComputerUseFlags: [Bool] = [] + let replacementController = ComputerUseFakeCodexController() + let viewModel = makeViewModel { _, _, _, _, _, _, computerUseEnabled in + requestedComputerUseFlags.append(computerUseEnabled) + return replacementController + } + let session = preparedCodexSession(in: viewModel, controller: existingController) + session.pendingCodexComputerUseActivation = .init(id: UUID(), createdAt: Date()) + session.codexControllerComputerUseEnabled = true + + viewModel.test_codexCoordinator.test_settleCodexComputerUseActivationAfterTurn(session) + + XCTAssertNil(session.pendingCodexComputerUseActivation) + XCTAssertNil(session.codexController) + XCTAssertFalse(session.codexControllerComputerUseEnabled) + + await viewModel.test_codexCoordinator.ensureCodexNativeSession( + session: session, + policyAlreadyInstalled: true, + allowMissingRolloutFallback: false, + allowResumeTimeoutFallback: false + ) + + XCTAssertEqual(requestedComputerUseFlags, [false]) + XCTAssertFalse(session.codexControllerComputerUseEnabled) + XCTAssertTrue(replacementController.startOrResumeCallCount > 0) + } + + func testComputerUseMCPElicitationAutoAcceptIsNarrowlyScoped() async throws { + let acceptingController = makeControllerForElicitation( + computerUseEnabled: true, + approvalPolicy: .never, + sandboxMode: .dangerFullAccess + ) + let result = await acceptingController.test_computerUseMCPElicitationAutoAcceptResult(params: [ + "mcpServer": "computer-use", + "prompt": "Computer Use wants to continue" + ]) + let accepted = try XCTUnwrap(result) + XCTAssertEqual(accepted["action"] as? String, "accept") + let meta = try XCTUnwrap(accepted["_meta"] as? [String: Any]) + XCTAssertEqual(meta["repoPromptAutoAccepted"] as? Bool, true) + XCTAssertEqual(meta["reason"] as? String, "explicit_computer_use_full_access") + + let disabledController = makeControllerForElicitation( + computerUseEnabled: false, + approvalPolicy: .never, + sandboxMode: .dangerFullAccess + ) + let disabledResult = await disabledController.test_computerUseMCPElicitationAutoAcceptResult(params: [ + "mcpServer": "computer-use" + ]) + XCTAssertNil(disabledResult) + + let sandboxedController = makeControllerForElicitation( + computerUseEnabled: true, + approvalPolicy: .never, + sandboxMode: .workspaceWrite + ) + let sandboxedResult = await sandboxedController.test_computerUseMCPElicitationAutoAcceptResult(params: [ + "mcpServer": "computer-use" + ]) + XCTAssertNil(sandboxedResult) + + XCTAssertTrue(CodexNativeSessionController.test_isComputerUseMCPElicitationRequest(params: [ + "mcp_server_name": "computer-use" + ])) + XCTAssertFalse(CodexNativeSessionController.test_isComputerUseMCPElicitationRequest(params: [ + "mcpServer": MCPIntegrationHelper.repoPromptMCPServerName + ])) + } + + private func makeViewModel( + factory: CodexAgentModeCoordinator.CodexControllerFactory? = nil + ) -> AgentModeViewModel { + AgentModeViewModel( + codexControllerFactory: { _, _, _, _, _, _ in ComputerUseFakeCodexController() }, + codexControllerFactoryWithComputerUse: factory, + connectionPolicyInstaller: { _, _, _, _, _, _, _, _, _, _, _, _, _ in } + ) + } + + private func preparedCodexSession( + in viewModel: AgentModeViewModel, + controller: ComputerUseFakeCodexController? = nil + ) -> AgentModeViewModel.TabSession { + let session = viewModel.session(for: UUID()) + session.selectedAgent = .codexExec + session.runID = UUID() + session.runState = .idle + session.codexController = controller + session.codexControllerGoalSupportEnabled = CodexGoalSupport.isEnabled + return session + } + + private func makeControllerForElicitation( + computerUseEnabled: Bool, + approvalPolicy: CodexAgentToolPreferences.ApprovalPolicy, + sandboxMode: CodexAgentToolPreferences.SandboxMode + ) -> CodexNativeSessionController { + CodexNativeSessionController( + client: CodexAppServerClient(), + runID: UUID(), + tabID: UUID(), + windowID: 1, + workspacePath: nil, + options: .agentModeDefault( + forceExperimentalSteering: true, + approvalPolicyProvider: { approvalPolicy }, + sandboxModeProvider: { sandboxMode }, + computerUseEnabledProvider: { computerUseEnabled } + ) + ) + } +} + +private final class ComputerUseFakeCodexController: CodexSessionControlling { + private(set) var startOrResumeCallCount = 0 + + var hasActiveThread: Bool { + true + } + + var events: AsyncStream { + AsyncStream { continuation in continuation.finish() } + } + + func ensureEventsStreamReady() {} + + func startOrResume(existing: CodexNativeSessionController.SessionRef?, baseInstructions: String) async throws -> CodexNativeSessionController.SessionRef { + startOrResumeCallCount += 1 + return CodexNativeSessionController.SessionRef(conversationID: "computer-use-test", rolloutPath: nil, model: nil, reasoningEffort: nil) + } + + func startOrResume(existing: CodexNativeSessionController.SessionRef?, baseInstructions: String, model: String?, reasoningEffort: String?) async throws -> CodexNativeSessionController.SessionRef { + startOrResumeCallCount += 1 + return CodexNativeSessionController.SessionRef(conversationID: "computer-use-test", rolloutPath: nil, model: model, reasoningEffort: reasoningEffort) + } + + func startOrResume(existing: CodexNativeSessionController.SessionRef?, baseInstructions: String, model: String?, reasoningEffort: String?, serviceTier: String?) async throws -> CodexNativeSessionController.SessionRef { + startOrResumeCallCount += 1 + return CodexNativeSessionController.SessionRef(conversationID: "computer-use-test", rolloutPath: nil, model: model, reasoningEffort: reasoningEffort) + } + + func readThreadSnapshot(includeTurns: Bool, timeout: TimeInterval?) async throws -> CodexNativeSessionController.ThreadSnapshot { + CodexNativeSessionController.ThreadSnapshot( + conversationID: "computer-use-test", + rolloutPath: nil, + model: nil, + reasoningEffort: nil, + runtimeStatus: .idle, + currentTurnID: nil, + activeTurnIDs: [], + latestTurnStatus: nil + ) + } + + func setThreadName(_ name: String, threadID: String?) async throws {} + func sendUserMessage(_ text: String) async throws {} + func sendUserTurn(text: String, images: [AgentImageAttachment]) async throws {} + func sendUserTurn(text: String, images: [AgentImageAttachment], model: String?, reasoningEffort: String?) async throws {} + func sendUserTurn(text: String, images: [AgentImageAttachment], model: String?, reasoningEffort: String?, serviceTier: String?) async throws {} + func compactThread() async throws {} + func getThreadGoal() async throws -> CodexNativeSessionController.ThreadGoal? { + nil + } + + func setThreadGoalObjective(_ objective: String) async throws -> CodexNativeSessionController.ThreadGoal { + throw CancellationError() + } + + func setThreadGoalStatus(_ status: CodexNativeSessionController.ThreadGoalStatus) async throws -> CodexNativeSessionController.ThreadGoal { + throw CancellationError() + } + + func clearThreadGoal() async throws -> Bool { + false + } + + func cancelCurrentTurn() async {} + func shutdown() async {} + func respondToServerRequest(id: CodexAppServerRequestID, result: [String: Any]) async {} +} diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift index 6cc98853a..5532f3920 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -88,6 +88,157 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { ) } + func testDefaultOverridesMaterializeAppManagedComputerUseRuntimeServer() { + let runtime = makeComputerUseRuntimeConfiguration( + source: .appManagedBundledPlugin( + mcpConfigPath: "/tmp/cua/.mcp.json", + manifestPath: "/tmp/cua/.codex-plugin/plugin.json", + version: "1.2.3" + ), + toolTimeoutSec: 456 + ) + + let overrides = deterministicDefaultOverrides( + computerUseEnabled: true, + resolution: .resolved(runtime) + ) + + XCTAssertEqual(overrides["features.computer_use"] as? Bool, true) + XCTAssertEqual(overrides["features.plugins"] as? Bool, true) + XCTAssertEqual(overrides["mcp_servers.computer-use.enabled"] as? Bool, true) + XCTAssertEqual(overrides["mcp_servers.computer-use.command"] as? String, "/tmp/cua/helper") + XCTAssertEqual(overrides["mcp_servers.computer-use.args"] as? [String], ["mcp", "--stdio"]) + XCTAssertEqual(overrides["mcp_servers.computer-use.cwd"] as? String, "/tmp/cua") + XCTAssertEqual(overrides["mcp_servers.computer-use.env"] as? [String: String], ["SKY_CUA_SERVICE_PATH": "/tmp/cua/service"]) + XCTAssertEqual(overrides["mcp_servers.computer-use.tool_timeout_sec"] as? Int, 456) + } + + func testDefaultOverridesOnlyEnableAndTimeoutExplicitComputerUseMCP() { + let runtime = makeComputerUseRuntimeConfiguration( + source: .explicitMCPServer(configPath: "/tmp/.codex/config.toml"), + toolTimeoutSec: nil + ) + + let overrides = deterministicDefaultOverrides( + computerUseEnabled: true, + resolution: .resolved(runtime) + ) + + XCTAssertEqual(overrides["mcp_servers.computer-use.enabled"] as? Bool, true) + XCTAssertEqual(overrides["mcp_servers.computer-use.tool_timeout_sec"] as? Int, 10000) + XCTAssertNil(overrides["mcp_servers.computer-use.command"]) + XCTAssertNil(overrides["mcp_servers.computer-use.args"]) + XCTAssertNil(overrides["mcp_servers.computer-use.cwd"]) + XCTAssertNil(overrides["mcp_servers.computer-use.env"]) + } + + func testDefaultOverridesSkipComputerUseRuntimeWhenDisabled() { + let runtime = makeComputerUseRuntimeConfiguration( + source: .appManagedBundledPlugin(mcpConfigPath: "/tmp/cua/.mcp.json", manifestPath: nil, version: nil), + toolTimeoutSec: 456 + ) + + let overrides = deterministicDefaultOverrides( + computerUseEnabled: false, + resolution: .resolved(runtime) + ) + + XCTAssertEqual(overrides["features.computer_use"] as? Bool, false) + XCTAssertNil(overrides["mcp_servers.computer-use.enabled"]) + XCTAssertNil(overrides["mcp_servers.computer-use.command"]) + XCTAssertNil(overrides["mcp_servers.computer-use.args"]) + XCTAssertNil(overrides["mcp_servers.computer-use.cwd"]) + XCTAssertNil(overrides["mcp_servers.computer-use.env"]) + XCTAssertNil(overrides["mcp_servers.computer-use.tool_timeout_sec"]) + } + + func testDefaultOverridesSkipIncompleteComputerUseRuntimeResolution() { + let overrides = deterministicDefaultOverrides( + computerUseEnabled: true, + resolution: .incomplete(.init(path: "/tmp/cua/.mcp.json", message: "missing command")) + ) + + XCTAssertEqual(overrides["features.computer_use"] as? Bool, true) + XCTAssertNil(overrides["mcp_servers.computer-use.enabled"]) + XCTAssertNil(overrides["mcp_servers.computer-use.command"]) + XCTAssertNil(overrides["mcp_servers.computer-use.args"]) + XCTAssertNil(overrides["mcp_servers.computer-use.cwd"]) + XCTAssertNil(overrides["mcp_servers.computer-use.env"]) + XCTAssertNil(overrides["mcp_servers.computer-use.tool_timeout_sec"]) + } + + func testAgentModeDefaultDoesNotResolveComputerUseRuntimeWhenDisabled() async throws { + var didResolveComputerUseRuntime = false + let options = CodexNativeSessionController.Options.agentModeDefault( + forceExperimentalSteering: true, + approvalPolicyProvider: { .never }, + sandboxModeProvider: { .readOnly }, + approvalReviewerProvider: { .user }, + computerUseEnabledProvider: { false }, + computerUseRuntimeConfigurationProvider: { + didResolveComputerUseRuntime = true + return .resolved(self.makeComputerUseRuntimeConfiguration( + source: .appManagedBundledPlugin(mcpConfigPath: "/tmp/cua/.mcp.json", manifestPath: nil, version: nil), + toolTimeoutSec: 456 + )) + } + ) + let (controller, recordURL) = try await makeController(options: options) + + _ = try await controller.startOrResume(existing: nil, baseInstructions: "Agent") + await controller.shutdown() + + XCTAssertFalse(didResolveComputerUseRuntime) + let params = try recordedParams(for: "thread/start", at: recordURL) + let config = try XCTUnwrap(params["config"] as? [String: Any]) + XCTAssertNil(config["mcp_servers.computer-use.command"]) + XCTAssertNil(config["mcp_servers.computer-use.args"]) + XCTAssertNil(config["mcp_servers.computer-use.cwd"]) + XCTAssertNil(config["mcp_servers.computer-use.env"]) + XCTAssertNil(config["mcp_servers.computer-use.tool_timeout_sec"]) + } + + private func deterministicDefaultOverrides( + computerUseEnabled: Bool, + resolution: CodexComputerUseRuntimeConfiguration.Resolution? + ) -> [String: Any] { + CodexNativeSessionController.defaultAppServerConfigOverrides( + forceExperimentalSteering: false, + approvalPolicy: .never, + sandboxMode: .readOnly, + approvalReviewer: .user, + shellToolEnabled: false, + goalSupportEnabled: false, + computerUseEnabled: computerUseEnabled, + computerUseRuntimeConfigurationResolution: resolution, + serverEntries: [], + preferences: .init( + bashToolEnabled: false, + searchToolEnabled: false, + approvalPolicy: .never, + sandboxMode: .readOnly, + approvalReviewer: .user, + enabledMCPServerNames: [] + ) + ) + } + + private func makeComputerUseRuntimeConfiguration( + source: CodexComputerUseRuntimeConfiguration.Source, + toolTimeoutSec: Int? + ) -> CodexComputerUseRuntimeConfiguration { + CodexComputerUseRuntimeConfiguration( + serverName: "computer-use", + command: "/tmp/cua/helper", + args: ["mcp", "--stdio"], + cwd: "/tmp/cua", + env: ["SKY_CUA_SERVICE_PATH": "/tmp/cua/service"], + enabled: nil, + toolTimeoutSec: toolTimeoutSec, + source: source + ) + } + private func makeController( options: CodexNativeSessionController.Options ) async throws -> (CodexNativeSessionController, URL) { diff --git a/Tests/RepoPromptTests/MCP/CodexIntegration/CodexIntegrationConfigurationTests.swift b/Tests/RepoPromptTests/MCP/CodexIntegration/CodexIntegrationConfigurationTests.swift index 80e141f89..a80b9a688 100644 --- a/Tests/RepoPromptTests/MCP/CodexIntegration/CodexIntegrationConfigurationTests.swift +++ b/Tests/RepoPromptTests/MCP/CodexIntegration/CodexIntegrationConfigurationTests.swift @@ -133,6 +133,39 @@ final class CodexIntegrationConfigurationTests: XCTestCase { XCTAssertTrue(entries.isEmpty) } + func testMCPServerConfigurationParserExtractsRuntimeFields() throws { + let content = """ + [mcp_servers."computer-use"] + command = "./bin/computer-use" + args = ["mcp", "--flag"] + cwd = "helpers" + enabled = false + tool_timeout_sec = 10_000 + + [mcp_servers."computer-use".env] + SKY_CUA_SERVICE_PATH = "./service" + TOKEN = "abc" + + [mcp_servers.RepoPromptCE.env] + IGNORED = "true" + """ + + let configuration = try XCTUnwrap(CodexIntegrationConfiguration.mcpServerConfiguration( + named: "Computer-Use", + fromConfigContent: content + )) + XCTAssertEqual(configuration.normalizedName, "computer-use") + XCTAssertEqual(configuration.command, "./bin/computer-use") + XCTAssertEqual(configuration.args, ["mcp", "--flag"]) + XCTAssertEqual(configuration.cwd, "helpers") + XCTAssertEqual(configuration.enabled, false) + XCTAssertEqual(configuration.toolTimeoutSec, 10000) + XCTAssertEqual(configuration.env, [ + "SKY_CUA_SERVICE_PATH": "./service", + "TOKEN": "abc" + ]) + } + func testPersistentMutationPreservesUnderscoredGlobalLimitAndStripsServerLevelLimit() { let input = """ tool_output_token_limit = 25_000 # user configured diff --git a/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift b/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift index 772fbb2c5..605786385 100644 --- a/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift +++ b/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift @@ -1,4 +1,5 @@ import Foundation +import MCP import XCTest @_spi(TestSupport) @testable import RepoPrompt @@ -48,6 +49,83 @@ final class SettingsJSONOnlyPersistenceTests: XCTestCase { XCTAssertFalse(store.respectGitignore()) } + func testCodexComputerUseOptInPersistsInJSONSettings() throws { + let temp = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let fileStore = GlobalSettingsFileStore(fileURL: fileURL) + let suiteName = "SettingsJSONOnlyPersistenceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + + XCTAssertFalse(store.codexComputerUseEnabled()) + store.setCodexComputerUseEnabled(true) + + let reloaded = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + XCTAssertTrue(reloaded.codexComputerUseEnabled()) + let rawJSON = try String(contentsOf: fileURL, encoding: .utf8) + XCTAssertTrue(rawJSON.contains("codexComputerUseEnabled")) + } + + func testCodexDiagnosticsSettingsUseUserDefaultsNotGlobalSettingsJSON() throws { + let temp = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let fileStore = GlobalSettingsFileStore(fileURL: fileURL) + let suiteName = "SettingsJSONOnlyPersistenceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + + XCTAssertFalse(store.codexRawEventLoggingEnabled()) + XCTAssertFalse(store.codexAppServerDiagnosticsEnabled()) + store.setCodexRawEventLoggingEnabled(true) + store.setCodexRawEventLogFilePath("/tmp/codex-raw") + store.setCodexAppServerDiagnosticsEnabled(true) + store.setCodexAppServerDiagnosticsLogFilePath("/tmp/codex-diag") + + let reloaded = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + XCTAssertTrue(reloaded.codexRawEventLoggingEnabled()) + XCTAssertEqual(reloaded.codexRawEventLogFilePath(), "/tmp/codex-raw") + XCTAssertTrue(reloaded.codexAppServerDiagnosticsEnabled()) + XCTAssertEqual(reloaded.codexAppServerDiagnosticsLogFilePath(), "/tmp/codex-diag") + + let rawJSON = try String(contentsOf: fileURL, encoding: .utf8) + XCTAssertFalse(rawJSON.contains("codexRawEventLoggingEnabled")) + XCTAssertFalse(rawJSON.contains("codexAppServerDiagnosticsEnabled")) + } + + func testCodexDiagnosticsAppSettingsAreExposedAndWritable() async throws { + let temp = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let suiteName = "SettingsJSONOnlyPersistenceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = GlobalSettingsStore( + defaults: defaults, + fileStore: GlobalSettingsFileStore(fileURL: fileURL) + ) + let service = AppSettingsMCPService(store: store) + let tools = await service.tools + let tool = try XCTUnwrap(tools.first { $0.name == AppSettingsMCPService.toolName }) + + let listValue = try await tool(["op": .string("list"), "group": .string("agent_mode")]) + let listObject = try XCTUnwrap(listValue.objectValue) + let settings = try XCTUnwrap(listObject["settings"]?.arrayValue) + let keys = Set(settings.compactMap { $0.objectValue?["key"]?.stringValue }) + XCTAssertTrue(keys.contains("agent_mode.codex_raw_event_logging_enabled")) + XCTAssertTrue(keys.contains("agent_mode.codex_app_server_diagnostics_enabled")) + + _ = try await tool([ + "op": .string("set"), + "key": .string("agent_mode.codex_app_server_diagnostics_enabled"), + "value": .bool(true) + ]) + XCTAssertTrue(store.codexAppServerDiagnosticsEnabled()) + } + func testWorktreeVisualIdentityDefaultsAreEmptyAndFallbackDoesNotPersist() throws { let temp = try makeTempDirectory() defer { try? FileManager.default.removeItem(at: temp) }