Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
27 changes: 25 additions & 2 deletions Sources/CodexBarCore/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +311 to +313

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve argv boundaries when matching the bundled executable

For a per-user installation where HOME contains whitespace (for example /Users/Jane Doe), DarwinProcessEnumerator.parseProcArgs2 joins argv with spaces, so both executableBasename and this split treat /Users/Jane as the executable. The process therefore never matches the allowed ~/Applications/ChatGPT.app/.../codex path, and ChatGPT-hosted activity remains undetected for those accounts. Preserve the executable path separately or retain argv boundaries instead of reparsing the flattened command.

Useful? React with 👍 / 👎.

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)
}
Expand All @@ -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 {
Expand Down
57 changes: 49 additions & 8 deletions Sources/CodexBarCore/LocalAgentSessionScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,26 @@ final class FutureModificationDateClamp: @unchecked Sendable {
}
}

private final class TrustedCodexAppServerCache: @unchecked Sendable {
private let lock = NSLock()
private var trustedExecutablePaths = Set<String>()

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
Expand All @@ -36,32 +53,40 @@ 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
}

init(
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
}

Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
[]
Expand All @@ -144,16 +182,18 @@ public struct LocalAgentSessionScanner: Sendable {
now: now,
codexAppServerPresent: codexAppServerPresent,
includeFileOnlySessions: includeFileOnlySessions,
includeTrustedCodexAppServerRollouts: includeTrustedCodexAppServerRollouts,
threadMetadata: threadMetadata,
piFamilySessions: piFamilySessions),
directoryBudget: &directoryBudget)
}

public static func shouldScanSessionMetadata(
hasAgentProcesses: Bool,
includeFileOnlySessions: Bool) -> Bool
includeFileOnlySessions: Bool,
hasTrustedCodexAppServer: Bool = false) -> Bool
{
hasAgentProcesses || includeFileOnlySessions
hasAgentProcesses || includeFileOnlySessions || hasTrustedCodexAppServer
}

private static func codexThreadMetadata(
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -124,6 +124,10 @@ struct AgentSessionMenuDescriptorTests {
#expect(LocalAgentSessionScanner.shouldScanSessionMetadata(
hasAgentProcesses: false,
includeFileOnlySessions: true))
#expect(LocalAgentSessionScanner.shouldScanSessionMetadata(
hasAgentProcesses: false,
includeFileOnlySessions: false,
hasTrustedCodexAppServer: true))
}

@Test
Expand Down
13 changes: 13 additions & 0 deletions Tests/CodexBarTests/AgentSessionParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
142 changes: 142 additions & 0 deletions Tests/CodexBarTests/CodexSessionRolloutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading