diff --git a/CHANGELOG.md b/CHANGELOG.md index 85eab65a6d..fab82f0e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed single-quota menu-bar icons understating remaining usage: a provider's only meaningful quota now renders as one prominent meter instead of half an icon next to a reserved empty lane, including when it arrives in the secondary slot (#3154, #3155). Thanks @akshayprabhu200! - Grok: report period-only CLI-proxy responses as unknown usage instead of 0% when Grok Build has hit its free limit, keeping identity and reset metadata (#3157, #3159). Thanks @anupamchugh! +- Refresh: detect ongoing Codex conversations hosted inside ChatGPT.app in Adaptive (agent-aware) mode without treating an idle open app as coding activity (#3160). - OpenRouter: request the latest completed UTC day from the Activity API instead of the current date, fixing the HTTP 400 that suppressed spend history (#3133, #3138). Thanks @kiranmagic7! - Qwen Cloud: restored Brave browser support in cookie import (#3148). Thanks @umutkeltek! - CLI install: report success even when unrelated PATH directories are non-writable, while still listing genuine `codexbar` conflicts from earlier PATH entries (#3153). Thanks @yicone! diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 372d552887..57a3b229c0 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -296,6 +296,28 @@ public enum AgentPSOutputParser { } } + static func chatGPTCodexAppServerExecutable( + in records: [AgentProcessRecord], + homeDirectory: URL) -> String? + { + let allowedPaths = Set([ + URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources/codex") + .standardizedFileURL.path, + homeDirectory.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex") + .standardizedFileURL.path, + ]) + + return records.lazy.compactMap { record -> String? in + guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue, + self.arguments(record.command).contains("app-server"), + let executable = record.command.split(whereSeparator: \ .isWhitespace).first + else { return nil } + + let path = URL(fileURLWithPath: String(executable)).standardizedFileURL.path + return allowedPaths.contains(path) ? path : nil + }.first + } + private static func arguments(_ command: String) -> [String] { command.split(whereSeparator: \ .isWhitespace).dropFirst().map(String.init) } @@ -320,8 +342,9 @@ public enum AgentPSOutputParser { private static func isCodexAgentExecutable(_ command: String) -> Bool { let lowercased = command.lowercased() guard lowercased.contains(".app/") else { return true } - return lowercased.hasPrefix("/applications/codex.app/contents/resources/codex ") || - lowercased.hasPrefix("/applications/codex.app/contents/resources/codex\t") + let executable = lowercased.split(whereSeparator: \ .isWhitespace).first.map(String.init) + return executable == "/applications/codex.app/contents/resources/codex" || + executable == "/applications/chatgpt.app/contents/resources/codex" } private static func isClaudeAgentExecutable(_ command: String) -> Bool { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 7bdd986877..cf41281930 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -20,9 +20,26 @@ final class FutureModificationDateClamp: @unchecked Sendable { } } +private final class TrustedCodexAppServerCache: @unchecked Sendable { + private let lock = NSLock() + private var trustedExecutablePaths = Set() + + func isTrusted(_ path: String, validator: @Sendable (String) -> Bool) -> Bool { + self.lock.withLock { + if self.trustedExecutablePaths.contains(path) { + return true + } + guard validator(path) else { return false } + self.trustedExecutablePaths.insert(path) + return true + } + } +} + public struct LocalAgentSessionScanner: Sendable { typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String] + typealias AppServerTrustValidator = @Sendable (String) -> Bool private struct Rollout: Sendable { let url: URL @@ -36,20 +53,24 @@ public struct LocalAgentSessionScanner: Sendable { let now: Date let codexAppServerPresent: Bool let includeFileOnlySessions: Bool + let includeTrustedCodexAppServerRollouts: Bool let threadMetadata: [String: CodexThreadMetadata] let piFamilySessions: [AgentSession] } public let config: SessionScanConfig private let futureModificationDateClamp = FutureModificationDateClamp() + private let trustedCodexAppServerCache = TrustedCodexAppServerCache() private let processOutputProvider: ProcessOutputProvider? private let cwdProvider: CWDProvider? + private let appServerTrustValidator: AppServerTrustValidator private let didVisitDirectoryEntry: (@Sendable () -> Void)? public init(config: SessionScanConfig = SessionScanConfig()) { self.config = config self.processOutputProvider = nil self.cwdProvider = nil + self.appServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) } self.didVisitDirectoryEntry = nil } @@ -57,11 +78,15 @@ public struct LocalAgentSessionScanner: Sendable { config: SessionScanConfig = SessionScanConfig(), processOutputProvider: @escaping ProcessOutputProvider, cwdProvider: @escaping CWDProvider, + appServerTrustValidator: @escaping AppServerTrustValidator = { + CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) + }, didVisitDirectoryEntry: (@Sendable () -> Void)? = nil) { self.config = config self.processOutputProvider = processOutputProvider self.cwdProvider = cwdProvider + self.appServerTrustValidator = appServerTrustValidator self.didVisitDirectoryEntry = didVisitDirectoryEntry } @@ -79,11 +104,22 @@ public struct LocalAgentSessionScanner: Sendable { let processes = Array(AgentSessionCorrelation.newestProcessesFirst( AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) + let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) + let trustedCodexAppServerPresent = if let executable = AgentPSOutputParser.chatGPTCodexAppServerExecutable( + in: allProcesses, + homeDirectory: homeDirectory) + { + self.trustedCodexAppServerCache.isTrusted(executable, validator: self.appServerTrustValidator) + } else { + false + } guard Self.shouldScanSessionMetadata( hasAgentProcesses: !processes.isEmpty, - includeFileOnlySessions: includeFileOnlySessions) + includeFileOnlySessions: includeFileOnlySessions, + hasTrustedCodexAppServer: trustedCodexAppServerPresent) else { return [] } - let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) + let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) || + trustedCodexAppServerPresent let cwdByPID = if let cwdProvider = self.cwdProvider { await cwdProvider(processes.map(\ .pid), environment) } else { @@ -93,7 +129,6 @@ public struct LocalAgentSessionScanner: Sendable { guard AgentPSOutputParser.provider(for: process) == .codex else { return nil } return cwdByPID[process.pid] } - let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) let codexHomeDirectory = URL( fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path, isDirectory: true) @@ -121,11 +156,14 @@ public struct LocalAgentSessionScanner: Sendable { host: host, config: self.config), directoryBudget: &piFamilyDirectoryBudget) - let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty { + let includeTrustedCodexAppServerRollouts = trustedCodexAppServerPresent && !includeFileOnlySessions + let rollouts: [Rollout] = if includeFileOnlySessions || !codexCWDs.isEmpty || + includeTrustedCodexAppServerRollouts + { self.codexRollouts( now: now, codexHomeDirectory: codexHomeDirectory, - matchingCWDs: includeFileOnlySessions ? nil : codexCWDs, + matchingCWDs: includeFileOnlySessions || includeTrustedCodexAppServerRollouts ? nil : codexCWDs, directoryBudget: &directoryBudget) } else { [] @@ -144,6 +182,7 @@ public struct LocalAgentSessionScanner: Sendable { now: now, codexAppServerPresent: codexAppServerPresent, includeFileOnlySessions: includeFileOnlySessions, + includeTrustedCodexAppServerRollouts: includeTrustedCodexAppServerRollouts, threadMetadata: threadMetadata, piFamilySessions: piFamilySessions), directoryBudget: &directoryBudget) @@ -151,9 +190,10 @@ public struct LocalAgentSessionScanner: Sendable { public static func shouldScanSessionMetadata( hasAgentProcesses: Bool, - includeFileOnlySessions: Bool) -> Bool + includeFileOnlySessions: Bool, + hasTrustedCodexAppServer: Bool = false) -> Bool { - hasAgentProcesses || includeFileOnlySessions + hasAgentProcesses || includeFileOnlySessions || hasTrustedCodexAppServer } private static func codexThreadMetadata( @@ -278,7 +318,8 @@ public struct LocalAgentSessionScanner: Sendable { } for rollout in rollouts - where context.includeFileOnlySessions && !matchedRolloutPaths.contains(rollout.url.path) + where (context.includeFileOnlySessions || context.includeTrustedCodexAppServerRollouts) && + !matchedRolloutPaths.contains(rollout.url.path) { guard var session = CodexRolloutFirstLineParser.makeSession( metadata: rollout.metadata, diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift index e971acf994..7f89d9ec2c 100644 --- a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -114,7 +114,7 @@ struct AgentSessionMenuDescriptorTests { } @Test - func `adaptive-only metadata reads require a detected agent process`() { + func `adaptive-only metadata reads require an agent or trusted codex app server`() { #expect(!LocalAgentSessionScanner.shouldScanSessionMetadata( hasAgentProcesses: false, includeFileOnlySessions: false)) @@ -124,6 +124,10 @@ struct AgentSessionMenuDescriptorTests { #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( hasAgentProcesses: false, includeFileOnlySessions: true)) + #expect(LocalAgentSessionScanner.shouldScanSessionMetadata( + hasAgentProcesses: false, + includeFileOnlySessions: false, + hasTrustedCodexAppServer: true)) } @Test diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift index 5c0d0d83ee..842347f6e5 100644 --- a/Tests/CodexBarTests/AgentSessionParserTests.swift +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -35,6 +35,19 @@ struct AgentSessionParserTests { } } + @Test + func `chatgpt bundled codex app server is recognized without becoming a live agent`() { + let records = AgentPSOutputParser.parse(""" + 4234 1 Mon Jul 6 09:03:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex \ + -c features.code_mode_host=true app-server --analytics-default-enabled + 20409 1 Mon Jul 6 09:04:00 2026 /Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host + """) + + #expect(records.count == 2) + #expect(AgentPSOutputParser.agentProcesses(from: records).isEmpty) + #expect(AgentPSOutputParser.hasCodexAppServer(in: records)) + } + @Test func `pi dialects use stable Codable raw values`() throws { let provider = try JSONEncoder().encode(AgentSession.Provider.pi) diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift index b2f9531224..d7c1f0b9c1 100644 --- a/Tests/CodexBarTests/CodexSessionRolloutTests.swift +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -68,6 +68,98 @@ struct CodexSessionRolloutTests { #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) } + @Test + func `trusted chatgpt app server projects recent codex rollout activity without an agent process`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + let session = try #require(sessions.first) + + #expect(sessions.count == 1) + #expect(session.provider == .codex) + #expect(session.source == .cli) + #expect(session.state == .active) + #expect(session.pid == nil) + #expect(try abs(#require(session.lastActivityAt).timeIntervalSince(now.addingTimeInterval(-30))) < 0.01) + } + + @Test + func `idle chatgpt app server with a stale rollout does not produce coding activity`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 31 * 60) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + + @Test + func `continuing an existing chatgpt codex rollout advances the adaptive activity signal`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let firstSessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + let firstActivity = try #require(firstSessions.first?.lastActivityAt) + let nextActivity = now.addingTimeInterval(20) + try FileManager.default.setAttributes( + [.modificationDate: nextActivity], + ofItemAtPath: fixture.rollout.path) + + let continuedSessions = await fixture.scanner.scan( + now: nextActivity.addingTimeInterval(1), + environment: fixture.environment, + includeFileOnlySessions: false) + let continuedActivity = try #require(continuedSessions.first?.lastActivityAt) + + #expect(continuedSessions.first?.id == firstSessions.first?.id) + #expect(continuedActivity > firstActivity) + #expect(abs(continuedActivity.timeIntervalSince(nextActivity)) < 0.01) + } + + @Test + func `untrusted chatgpt app server cannot authorize adaptive rollout inspection`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture(now: now, rolloutAge: 30, appServerIsTrusted: false) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + + @Test + func `unrelated chatgpt named bundle cannot authorize adaptive rollout inspection`() async throws { + let now = Date() + let fixture = try Self.makeAdaptiveChatGPTFixture( + now: now, + rolloutAge: 30, + appServerExecutable: "/tmp/ChatGPT.app/Contents/Resources/codex") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let sessions = await fixture.scanner.scan( + now: now, + environment: fixture.environment, + includeFileOnlySessions: false) + + #expect(sessions.isEmpty) + } + @Test func `local scanner parses only its newest configured rollout candidates`() async throws { let fileManager = FileManager.default @@ -261,6 +353,56 @@ struct CodexSessionRolloutTests { #expect(sessions.allSatisfy { $0.sessionName == nil }) } + private struct AdaptiveChatGPTFixture { + let root: URL + let rollout: URL + let scanner: LocalAgentSessionScanner + let environment: [String: String] + } + + private static func makeAdaptiveChatGPTFixture( + now: Date, + rolloutAge: TimeInterval, + appServerExecutable: String = "/Applications/ChatGPT.app/Contents/Resources/codex", + appServerIsTrusted: Bool = true) throws -> AdaptiveChatGPTFixture + { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("CodexSessionRolloutTests-chatgpt-\(UUID().uuidString)", isDirectory: true) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let codexHome = root.appendingPathComponent("codex-home", isDirectory: true) + let sessionDirectory = codexHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(formatter.string(from: now), isDirectory: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + + let source = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let rollout = sessionDirectory.appendingPathComponent("rollout-chatgpt-existing.jsonl") + try fileManager.copyItem(at: source, to: rollout) + try fileManager.setAttributes( + [.modificationDate: now.addingTimeInterval(-rolloutAge)], + ofItemAtPath: rollout.path) + + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + "4234 1 Mon Jul 6 09:03:00 2026 \(appServerExecutable) " + + "-c features.code_mode_host=true app-server --analytics-default-enabled" + }, + cwdProvider: { _, _ in [:] }, + appServerTrustValidator: { _ in appServerIsTrusted }) + return AdaptiveChatGPTFixture( + root: root, + rollout: rollout, + scanner: scanner, + environment: [ + "CODEX_HOME": codexHome.path, + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + ]) + } + #if canImport(SQLite3) || canImport(CSQLite3) @Test func `scanner resolves relative sqlite homes for multiple session projects`() async throws { diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ae693c4a31..78e20abd55 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1378,7 +1378,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 258, + line: 298, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1528,19 +1528,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This logged-out-page classifier matches OpenAI's public landing-page brand token."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 389, + line: 412, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 463, + line: 486, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The budgeted Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 570, + line: 593, anchor: "if value.contains(\"ide\") || value.contains(\"vscode\") || value.contains(\"cursor\") || value.contains(\"zed\") {", expectedProviderIDs: ["cursor", "zed"], reason: "This session-source classifier recognizes editor-origin strings emitted by upstream clients."), @@ -3432,7 +3432,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 306, + line: 311, + anchor: "guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "This exact host integration recognizes only the Codex app-server bundled in ChatGPT.app."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/AgentSession.swift", + line: 328, anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3504,15 +3512,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 93, + line: 129, anchor: "guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@5"], + expectedReferenceFingerprint: ["codex@0", "codex@4"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 205, + line: 245, anchor: "let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3520,7 +3528,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 221, + line: 261, anchor: "let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3528,7 +3536,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 247, + line: 287, anchor: "case .codex:", expectedProviderIDs: ["codex"], expectedReferenceCount: 1,