diff --git a/Sources/Codexer/CodexerModel.swift b/Sources/Codexer/CodexerModel.swift index fc695f6..56b7aa6 100644 --- a/Sources/Codexer/CodexerModel.swift +++ b/Sources/Codexer/CodexerModel.swift @@ -8,6 +8,13 @@ enum CodexerSidebarSelection: Hashable { case profile(CodexProfile.ID) } +enum ActivityRefreshScope: Equatable { + case none + case stats + case quota + case all +} + enum AgentDockDetailTab: String, CaseIterable, Identifiable { case overview case advanced @@ -1286,10 +1293,47 @@ final class CodexerModel: ObservableObject { func refreshActivityIfStale(now: Date = Date()) { guard isApplicationActive, preferences.refreshProfileActivity, statsRefreshTask == nil, rateLimitRefreshTask == nil else { return } let interval = Double(max(1, preferences.refreshIntervalMinutes) * 60) - let statsStale = lastStatsRefreshAt.map { now.timeIntervalSince($0) >= interval } ?? true - let quotaStale = lastQuotaRefreshAt.map { now.timeIntervalSince($0) >= interval } ?? true - let canRetry = lastQuotaAttemptAt.map { now.timeIntervalSince($0) >= 60 } ?? true - if statsStale || (quotaStale && canRetry) { refreshStats() } + switch Self.activityRefreshScope( + now: now, + statsRefreshedAt: lastStatsRefreshAt, + quotaRefreshedAt: lastQuotaRefreshAt, + quotaAttemptedAt: lastQuotaAttemptAt, + refreshInterval: interval + ) { + case .all: + refreshStats() + case .stats: + refreshConfigProfiles() + refreshStats(for: profiles, replaceAll: true) + case .quota: + refreshRateLimits() + case .none: + break + } + } + + static func activityRefreshScope( + now: Date, + statsRefreshedAt: Date?, + quotaRefreshedAt: Date?, + quotaAttemptedAt: Date?, + refreshInterval: TimeInterval + ) -> ActivityRefreshScope { + let statsStale = statsRefreshedAt.map { + now.timeIntervalSince($0) >= refreshInterval + } ?? true + let quotaStale = quotaRefreshedAt.map { + now.timeIntervalSince($0) >= refreshInterval + } ?? true + let canRetryQuota = quotaAttemptedAt.map { + now.timeIntervalSince($0) >= 60 + } ?? true + return switch (statsStale, quotaStale && canRetryQuota) { + case (true, true): .all + case (true, false): .stats + case (false, true): .quota + case (false, false): .none + } } func refreshConfigProfiles() { @@ -1522,10 +1566,6 @@ final class CodexerModel: ObservableObject { busyProfileIDs.insert(profile.id) Task { [weak self] in guard let self else { return } - defer { - storeMutationInProgress = false - busyProfileIDs.remove(profile.id) - } do { if wasRunning { _ = try await instanceController.close(profile: profile, appURL: appURL) @@ -1558,6 +1598,10 @@ final class CodexerModel: ObservableObject { selectProfile(profile.id) present(error, code: .persistenceFailed, provider: .codex, action: .configured) } + // The profile mutation and any required restart are complete. Do not + // reject a following profile edit while the independent status read runs. + storeMutationInProgress = false + busyProfileIDs.remove(profile.id) await refreshInstanceStatuses() } } diff --git a/Sources/CodexerCore/ClaudeLauncher.swift b/Sources/CodexerCore/ClaudeLauncher.swift index bea9c4b..fed20db 100644 --- a/Sources/CodexerCore/ClaudeLauncher.swift +++ b/Sources/CodexerCore/ClaudeLauncher.swift @@ -43,9 +43,15 @@ public enum ClaudeInstanceDiscovery { public static func profileMainProcessIDs( in snapshot: ClaudeProcessSnapshot, appURL: URL, - userDataURL: URL + userDataURL: URL, + trustedHelperProcessIDs: Set ) -> [Int32] { - mainProcessIDs(in: snapshot, appURL: appURL, userDataURL: userDataURL) + mainProcessIDs( + in: snapshot, + appURL: appURL, + userDataURL: userDataURL, + trustedHelperProcessIDs: trustedHelperProcessIDs + ) } public static func stockMainProcessIDs( @@ -54,18 +60,29 @@ public enum ClaudeInstanceDiscovery { defaultUserDataURL: URL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - )[0].appendingPathComponent("Claude", isDirectory: true) + )[0].appendingPathComponent("Claude", isDirectory: true), + trustedHelperProcessIDs: Set ) -> [Int32] { - mainProcessIDs(in: snapshot, appURL: appURL, userDataURL: defaultUserDataURL) + mainProcessIDs( + in: snapshot, + appURL: appURL, + userDataURL: defaultUserDataURL, + trustedHelperProcessIDs: trustedHelperProcessIDs + ) } private static func mainProcessIDs( in snapshot: ClaudeProcessSnapshot, appURL: URL, - userDataURL: URL + userDataURL: URL, + trustedHelperProcessIDs: Set ) -> [Int32] { let canonicalUserDataPath = canonical(userDataURL) - return userDataPathsByMainProcess(in: snapshot, appURL: appURL) + return userDataPathsByMainProcess( + in: snapshot, + appURL: appURL, + trustedHelperProcessIDs: trustedHelperProcessIDs + ) .compactMap { processID, paths in paths == [canonicalUserDataPath] ? processID : nil }.sorted() @@ -73,7 +90,8 @@ public enum ClaudeInstanceDiscovery { static func userDataPathsByMainProcess( in snapshot: ClaudeProcessSnapshot, - appURL: URL + appURL: URL, + trustedHelperProcessIDs: Set ) -> [Int32: Set] { let contentsPrefix = appURL.standardizedFileURL .appendingPathComponent("Contents", isDirectory: true) @@ -86,6 +104,7 @@ public enum ClaudeInstanceDiscovery { var pathsByMainProcess: [Int32: Set] = [:] for entry in snapshot.entries.values { guard entry.command.hasPrefix(contentsPrefix), + trustedHelperProcessIDs.contains(entry.processID), let mainProcessID = mainAncestor( from: entry.processID, snapshot: snapshot, @@ -201,7 +220,13 @@ public actor ClaudeInstanceController { try Task.checkCancellation() let snapshot = try snapshotProvider.snapshot() try Task.checkCancellation() - return statuses(for: profiles, appURL: appURL, snapshot: snapshot) + let trustedHelpers = trustedHelperProcessIDs(in: snapshot, appURL: appURL) + return statuses( + for: profiles, + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelpers + ) } func statusBatch( @@ -213,25 +238,35 @@ public actor ClaudeInstanceController { try Task.checkCancellation() let snapshot = try snapshotProvider.snapshot() try Task.checkCancellation() + let trustedHelpers = trustedHelperProcessIDs(in: snapshot, appURL: appURL) return ProviderInstanceStatusBatch( - managedStatuses: statuses(for: profiles, appURL: appURL, snapshot: snapshot), - officialStatus: stockStatus(appURL: appURL, snapshot: snapshot) + managedStatuses: statuses( + for: profiles, + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelpers + ), + officialStatus: stockStatus( + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelpers + ) ) } private func statuses( for profiles: [CodexProfile], appURL: URL, - snapshot: ClaudeProcessSnapshot + snapshot: ClaudeProcessSnapshot, + trustedHelperProcessIDs: Set ) -> [CodexProfile.ID: CodexInstanceStatus] { return Dictionary(uniqueKeysWithValues: profiles.map { profile in let trustedProcessIDs = ClaudeInstanceDiscovery.profileMainProcessIDs( in: snapshot, appURL: appURL, - userDataURL: profile.claudeUserDataPath - ).filter { - validator.isTrustedProcess(processID: $0, product: .claude) - } + userDataURL: profile.claudeUserDataPath, + trustedHelperProcessIDs: trustedHelperProcessIDs + ).filter { isTrustedMainProcess($0, appURL: appURL) } return ( profile.id, CodexInstanceStatus( @@ -258,17 +293,24 @@ public actor ClaudeInstanceController { try Task.checkCancellation() let snapshot = try snapshotProvider.snapshot() try Task.checkCancellation() - return stockStatus(appURL: appURL, snapshot: snapshot) + return stockStatus( + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelperProcessIDs(in: snapshot, appURL: appURL) + ) } - private func stockStatus(appURL: URL, snapshot: ClaudeProcessSnapshot) -> CodexInstanceStatus { + private func stockStatus( + appURL: URL, + snapshot: ClaudeProcessSnapshot, + trustedHelperProcessIDs: Set + ) -> CodexInstanceStatus { return CodexInstanceStatus( processIDs: ClaudeInstanceDiscovery.stockMainProcessIDs( in: snapshot, - appURL: appURL - ).filter { - validator.isTrustedProcess(processID: $0, product: .claude) - } + appURL: appURL, + trustedHelperProcessIDs: trustedHelperProcessIDs + ).filter { isTrustedMainProcess($0, appURL: appURL) } ) } @@ -279,15 +321,16 @@ public actor ClaudeInstanceController { let operationLock = try await ProfileOperationLock.acquire(for: profile.profileDirectory) defer { withExtendedLifetime(operationLock) {} } try validateProfileLayout(profile) + try validateClaudeApp(at: appURL) - if let processID = try status(for: profile, appURL: appURL).primaryProcessID { - guard activate(processID: processID) else { + if let processID = try verifiedStatus(for: profile, appURL: appURL).primaryProcessID { + guard try activateVerified(processID: processID, profile: profile, appURL: appURL) else { throw ClaudeLauncherError.couldNotFocus(processID) } return .focused(processID: processID) } - try validateClaudeApp(at: appURL) + try validator.validateApp(at: appURL, product: .claude) let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true configuration.addsToRecentItems = false @@ -314,20 +357,21 @@ public actor ClaudeInstanceController { // unverified process running instead of risking another active profile. try await waitForLaunch( expectedProcessID: processID, - status: { try self.status(for: profile, appURL: appURL) } + status: { try self.verifiedStatus(for: profile, appURL: appURL) } ) return .launched(processID: processID) } public func openStock(appURL: URL) async throws -> CodexOpenOutcome { - if let processID = try stockStatus(appURL: appURL).primaryProcessID { - guard activate(processID: processID) else { + try validateClaudeApp(at: appURL) + if let processID = try verifiedStockStatus(appURL: appURL).primaryProcessID { + guard try activateVerifiedStock(processID: processID, appURL: appURL) else { throw ClaudeLauncherError.couldNotFocus(processID) } return .focused(processID: processID) } - try validateClaudeApp(at: appURL) + try validator.validateApp(at: appURL, product: .claude) let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true configuration.addsToRecentItems = false @@ -349,7 +393,7 @@ public actor ClaudeInstanceController { try Self.validateLaunchedProcessID(processID, existingProcessIDs: existingProcessIDs) try await waitForLaunch( expectedProcessID: processID, - status: { try self.stockStatus(appURL: appURL) } + status: { try self.verifiedStockStatus(appURL: appURL) } ) return .launched(processID: processID) } @@ -361,7 +405,8 @@ public actor ClaudeInstanceController { let operationLock = try await ProfileOperationLock.acquire(for: profile.profileDirectory) defer { withExtendedLifetime(operationLock) {} } try validateProfileLayout(profile) - let processIDs = try status(for: profile, appURL: appURL).processIDs + try validateClaudeApp(at: appURL) + let processIDs = try verifiedStatus(for: profile, appURL: appURL).processIDs guard !processIDs.isEmpty else { return .alreadyStopped } let tree = try processTreeProvider.processTreeSnapshot() @@ -379,6 +424,14 @@ public actor ClaudeInstanceController { return process } + for processID in processIDs { + guard let identity = capturedProcesses.first(where: { $0.processID == processID }), + try isVerifiedProfileProcess(identity, profile: profile, appURL: appURL) + else { + throw ClaudeLauncherError.couldNotTerminate(processID) + } + } + try processSignaler.signal( SIGTERM, identities: Array(capturedProcesses.reversed()) @@ -388,7 +441,7 @@ public actor ClaudeInstanceController { let deadline = clock.now.advanced(by: .seconds(5)) while clock.now < deadline { try Task.checkCancellation() - if try status(for: profile, appURL: appURL).processIDs.isEmpty, + if try verifiedStatus(for: profile, appURL: appURL).processIDs.isEmpty, try remainingCapturedProcesses(capturedProcesses).isEmpty { return .closed(processIDs: processIDs) @@ -403,7 +456,7 @@ public actor ClaudeInstanceController { let killDeadline = clock.now.advanced(by: .seconds(2)) while clock.now < killDeadline { try Task.checkCancellation() - if try status(for: profile, appURL: appURL).processIDs.isEmpty, + if try verifiedStatus(for: profile, appURL: appURL).processIDs.isEmpty, try remainingCapturedProcesses(capturedProcesses).isEmpty { return .closed(processIDs: processIDs) @@ -433,13 +486,134 @@ public actor ClaudeInstanceController { throw ClaudeLauncherError.launchedProcessFailedValidation(expectedProcessID) } - private func activate(processID: Int32) -> Bool { - guard let application = NSRunningApplication(processIdentifier: processID) else { + private func verifiedStatus( + for profile: CodexProfile, + appURL: URL + ) throws -> CodexInstanceStatus { + let snapshot = try snapshotProvider.snapshot() + let trustedHelpers = trustedHelperProcessIDs(in: snapshot, appURL: appURL) + return statuses( + for: [profile], + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelpers + )[profile.id] ?? CodexInstanceStatus() + } + + private func verifiedStockStatus(appURL: URL) throws -> CodexInstanceStatus { + let snapshot = try snapshotProvider.snapshot() + return stockStatus( + appURL: appURL, + snapshot: snapshot, + trustedHelperProcessIDs: trustedHelperProcessIDs(in: snapshot, appURL: appURL) + ) + } + + private func trustedHelperProcessIDs( + in snapshot: ClaudeProcessSnapshot, + appURL: URL + ) -> Set { + let contentsPrefix = appURL.standardizedFileURL + .appendingPathComponent("Contents", isDirectory: true).path + "/" + return Set(snapshot.entries.values.compactMap { entry -> Int32? in + guard entry.command.hasPrefix(contentsPrefix), + entry.command.contains("--user-data-dir="), + let startKeyBefore = SystemProcessTreeSnapshotProvider.kernelStartKey( + for: entry.processID + ), + let executableURL = SystemProcessTreeSnapshotProvider.executableURL( + for: entry.processID + ), + CodexInstanceDiscovery.isExecutable( + executableURL, + containedBy: appURL.appendingPathComponent("Contents") + ), + validator.isTrustedPublisherProcess( + processID: entry.processID, + product: .claude + ), + SystemProcessTreeSnapshotProvider.kernelStartKey(for: entry.processID) + == startKeyBefore + else { + return nil + } + return entry.processID + }) + } + + private func isTrustedMainProcess(_ processID: Int32, appURL: URL) -> Bool { + guard let startKeyBefore = SystemProcessTreeSnapshotProvider.kernelStartKey(for: processID), + validator.isTrustedProcess(processID: processID, product: .claude), + let executableURL = SystemProcessTreeSnapshotProvider.executableURL(for: processID) + else { + return false + } + let hasExpectedExecutable = executableURL.standardizedFileURL.resolvingSymlinksInPath() + == DesktopAppRegistry.claude.executableURL(for: appURL) + .standardizedFileURL.resolvingSymlinksInPath() + return hasExpectedExecutable + && SystemProcessTreeSnapshotProvider.kernelStartKey(for: processID) == startKeyBefore + } + + private func activateVerified( + processID: Int32, + profile: CodexProfile, + appURL: URL + ) throws -> Bool { + guard let identity = try processTreeProvider.processTreeSnapshot().first(where: { + $0.processID == processID + }) else { + return false + } + var stableIdentity = identity + stableIdentity.kernelStartKey = SystemProcessTreeSnapshotProvider.kernelStartKey(for: processID) + guard try isVerifiedProfileProcess(stableIdentity, profile: profile, appURL: appURL), + let application = NSRunningApplication(processIdentifier: processID), + SystemProcessTreeSnapshotProvider.kernelStartKey(for: processID) + == stableIdentity.kernelStartKey + else { return false } return application.activate(options: [.activateAllWindows]) } + private func activateVerifiedStock(processID: Int32, appURL: URL) throws -> Bool { + guard let identity = try processTreeProvider.processTreeSnapshot().first(where: { + $0.processID == processID + }) else { + return false + } + let kernelStartKey = SystemProcessTreeSnapshotProvider.kernelStartKey(for: processID) + guard kernelStartKey != nil, + isTrustedMainProcess(processID, appURL: appURL), + try verifiedStockStatus(appURL: appURL).processIDs.contains(processID), + let application = NSRunningApplication(processIdentifier: processID), + SystemProcessTreeSnapshotProvider.kernelStartKey(for: identity.processID) + == kernelStartKey + else { + return false + } + return application.activate(options: [.activateAllWindows]) + } + + private func isVerifiedProfileProcess( + _ identity: ProcessIdentity, + profile: CodexProfile, + appURL: URL + ) throws -> Bool { + guard let kernelStartKey = identity.kernelStartKey, + SystemProcessTreeSnapshotProvider.kernelStartKey(for: identity.processID) + == kernelStartKey, + isTrustedMainProcess(identity.processID, appURL: appURL), + try verifiedStatus(for: profile, appURL: appURL).processIDs + .contains(identity.processID) + else { + return false + } + return SystemProcessTreeSnapshotProvider.kernelStartKey(for: identity.processID) + == kernelStartKey + } + private func remainingCapturedProcesses( _ captured: [ProcessIdentity] ) throws -> [ProcessIdentity] { diff --git a/Sources/CodexerCore/DesktopAppRegistry.swift b/Sources/CodexerCore/DesktopAppRegistry.swift index bc991d2..45b0a29 100644 --- a/Sources/CodexerCore/DesktopAppRegistry.swift +++ b/Sources/CodexerCore/DesktopAppRegistry.swift @@ -97,6 +97,29 @@ public struct OfficialDesktopAppValidator: @unchecked Sendable { public func isTrustedProcess( processID: Int32, product: DesktopProduct + ) -> Bool { + isTrustedProcess( + processID: processID, + requirementText: Self.requirementText( + for: DesktopAppRegistry.descriptor(for: product) + ) + ) + } + + public func isTrustedPublisherProcess( + processID: Int32, + product: DesktopProduct + ) -> Bool { + let descriptor = DesktopAppRegistry.descriptor(for: product) + return isTrustedProcess( + processID: processID, + requirementText: "anchor apple generic and certificate leaf[subject.OU] = \"\(descriptor.teamIdentifier)\"" + ) + } + + private func isTrustedProcess( + processID: Int32, + requirementText: String ) -> Bool { let attributes = [ kSecGuestAttributePid as String: NSNumber(value: processID) @@ -108,10 +131,9 @@ public struct OfficialDesktopAppValidator: @unchecked Sendable { return false } - let descriptor = DesktopAppRegistry.descriptor(for: product) var requirement: SecRequirement? guard SecRequirementCreateWithString( - Self.requirementText(for: descriptor) as CFString, + requirementText as CFString, [], &requirement ) == errSecSuccess, diff --git a/Tests/CodexerAppTests/CodexerModelTests.swift b/Tests/CodexerAppTests/CodexerModelTests.swift index 0749ea5..73f7abb 100644 --- a/Tests/CodexerAppTests/CodexerModelTests.swift +++ b/Tests/CodexerAppTests/CodexerModelTests.swift @@ -106,6 +106,51 @@ final class CodexerModelTests: XCTestCase { XCTAssertFalse(model.officialStatsLoading) } + func testActivityRefreshScopeSeparatesFreshStatsFromStaleQuota() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + XCTAssertEqual( + CodexerModel.activityRefreshScope( + now: now, + statsRefreshedAt: now.addingTimeInterval(-60), + quotaRefreshedAt: nil, + quotaAttemptedAt: now.addingTimeInterval(-61), + refreshInterval: 5 * 60 + ), + .quota + ) + XCTAssertEqual( + CodexerModel.activityRefreshScope( + now: now, + statsRefreshedAt: nil, + quotaRefreshedAt: now.addingTimeInterval(-60), + quotaAttemptedAt: now.addingTimeInterval(-60), + refreshInterval: 5 * 60 + ), + .stats + ) + XCTAssertEqual( + CodexerModel.activityRefreshScope( + now: now, + statsRefreshedAt: nil, + quotaRefreshedAt: nil, + quotaAttemptedAt: now.addingTimeInterval(-59), + refreshInterval: 5 * 60 + ), + .stats + ) + XCTAssertEqual( + CodexerModel.activityRefreshScope( + now: now, + statsRefreshedAt: now.addingTimeInterval(-60), + quotaRefreshedAt: nil, + quotaAttemptedAt: now.addingTimeInterval(-59), + refreshInterval: 5 * 60 + ), + .none + ) + } + func testStoreMutationIsRejectedWhileLaunchIsInFlight() async throws { let store = try makeStore() let profile = try store.createProfile(name: "Existing") @@ -456,7 +501,8 @@ final class CodexerModelTests: XCTestCase { officialDataRootURL: root.appendingPathComponent("Official"), codexAppURL: appURL, shortcutInstaller: installer, - startMonitoring: false + startMonitoring: false, + loadActivityOnInit: false ) model.updateProfile( profile, @@ -518,7 +564,9 @@ final class CodexerModelTests: XCTestCase { instanceController: DesktopInstanceController(), shortcutInstaller: ShortcutInstaller(), statsScanner: ProfileStatsScanner(), - rateLimitClient: AppServerRateLimitClient() + rateLimitClient: AppServerRateLimitClient(), + startMonitoring: false, + loadActivityOnInit: false ) } diff --git a/Tests/CodexerCoreTests/AppServerRateLimitClientTests.swift b/Tests/CodexerCoreTests/AppServerRateLimitClientTests.swift index 4a18ecc..66066f5 100644 --- a/Tests/CodexerCoreTests/AppServerRateLimitClientTests.swift +++ b/Tests/CodexerCoreTests/AppServerRateLimitClientTests.swift @@ -42,6 +42,25 @@ final class AppServerRateLimitClientTests: XCTestCase { try? FileManager.default.removeItem(at: root) } + func testInstalledAppServerRespondsFromAnIsolatedHomeWhenEnabled() throws { + guard ProcessInfo.processInfo.environment["AGENTDOCK_INSTALLED_APP_TEST"] == "1" else { + throw XCTSkip("Set AGENTDOCK_INSTALLED_APP_TEST=1 to exercise the installed Codex app-server.") + } + let appURL = installedProviderAppURL(.codex) + try OfficialCodexAppValidator().validateCodexApp(at: appURL) + try CodexMCPConfiguration.configure(codexHomeURL: root, callbackPort: 49_152) + + let limits = AppServerRateLimitClient().fetchRateLimits( + codexHomeURL: root, + codexAppURL: appURL + ) + + // An unauthenticated RPC response proves initialization and request/response + // compatibility without reading an existing profile or making an API call. + XCTAssertEqual(limits.errorMessage, RateLimitParserError.missingResult.errorDescription) + XCTAssertTrue(limits.buckets.isEmpty) + } + func testUsesProfileCodexHomeAndAcceptsWhitespaceInResponseID() throws { let profile = CodexProfile(name: "Work", slug: "work", rootDirectory: root) try FileManager.default.createDirectory(at: profile.codexHomePath, withIntermediateDirectories: true) diff --git a/Tests/CodexerCoreTests/ClaudeDesktopTests.swift b/Tests/CodexerCoreTests/ClaudeDesktopTests.swift index bb3d748..5f72fc3 100644 --- a/Tests/CodexerCoreTests/ClaudeDesktopTests.swift +++ b/Tests/CodexerCoreTests/ClaudeDesktopTests.swift @@ -156,7 +156,8 @@ final class ClaudeDesktopTests: XCTestCase { ClaudeInstanceDiscovery.profileMainProcessIDs( in: snapshot, appURL: appURL, - userDataURL: profileA + userDataURL: profileA, + trustedHelperProcessIDs: [101, 201, 301, 401, 500] ), [100] ) @@ -164,7 +165,8 @@ final class ClaudeDesktopTests: XCTestCase { ClaudeInstanceDiscovery.profileMainProcessIDs( in: snapshot, appURL: appURL, - userDataURL: profileB + userDataURL: profileB, + trustedHelperProcessIDs: [101, 201, 301, 401, 500] ), [200] ) @@ -172,7 +174,8 @@ final class ClaudeDesktopTests: XCTestCase { ClaudeInstanceDiscovery.stockMainProcessIDs( in: snapshot, appURL: appURL, - defaultUserDataURL: stock + defaultUserDataURL: stock, + trustedHelperProcessIDs: [101, 201, 301, 401, 500] ), [300] ) @@ -200,11 +203,48 @@ final class ClaudeDesktopTests: XCTestCase { XCTAssertTrue(ClaudeInstanceDiscovery.profileMainProcessIDs( in: snapshot, appURL: appURL, - userDataURL: profile + userDataURL: profile, + trustedHelperProcessIDs: [101, 201, 301, 401, 402] ).isEmpty) } } + func testDiscoveryRejectsHelperCommandWithoutKernelTrust() { + let appURL = URL(fileURLWithPath: "/Applications/Claude.app", isDirectory: true) + let profile = URL(fileURLWithPath: "/tmp/Profile B/UserData", isDirectory: true) + let executable = "/Applications/Claude.app/Contents/MacOS/Claude" + let forgedHelper = "/Applications/Claude.app/Contents/Frameworks/Claude Helper.app/Contents/MacOS/Claude Helper" + let snapshot = ClaudeProcessSnapshot(text: """ + 200 1 \(executable) + 201 200 \(forgedHelper) --type=renderer --user-data-dir=\(profile.path) + """) + + XCTAssertEqual( + ClaudeInstanceDiscovery.profileMainProcessIDs( + in: snapshot, + appURL: appURL, + userDataURL: profile, + trustedHelperProcessIDs: [] + ), + [] + ) + } + + func testUnrelatedLocalProcessCannotClaimClaudePublisherIdentity() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["2"] + try process.run() + defer { SubprocessTerminator.terminateAndWait(process) } + + XCTAssertFalse( + OfficialDesktopAppValidator().isTrustedPublisherProcess( + processID: process.processIdentifier, + product: .claude + ) + ) + } + func testClaudeProfilesUseProductScopedStateAndPreserveCodexLayout() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("Codexer-ClaudeProfiles-\(UUID().uuidString)") @@ -275,7 +315,7 @@ final class ClaudeDesktopTests: XCTestCase { "Set AGENTDOCK_INSTALLED_CLAUDE_TEST=1 to validate the installed Claude Desktop app." ) } - let appURL = DesktopAppRegistry.claude.defaultAppURL + let appURL = installedProviderAppURL(.claude) try OfficialDesktopAppValidator().validateApp(at: appURL, product: .claude) try ClaudeDesktopContractProbe().validate(appURL: appURL) } @@ -286,7 +326,7 @@ final class ClaudeDesktopTests: XCTestCase { "Set AGENTDOCK_CLAUDE_LIVE_LIFECYCLE=1 to exercise a temporary Claude profile." ) } - let appURL = DesktopAppRegistry.claude.defaultAppURL + let appURL = installedProviderAppURL(.claude) let root = FileManager.default.temporaryDirectory .appendingPathComponent("Codexer-ClaudeLive-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/CodexerCoreTests/CodexConfigProfileTests.swift b/Tests/CodexerCoreTests/CodexConfigProfileTests.swift index a8b481b..a52b0eb 100644 --- a/Tests/CodexerCoreTests/CodexConfigProfileTests.swift +++ b/Tests/CodexerCoreTests/CodexConfigProfileTests.swift @@ -151,9 +151,9 @@ final class CodexConfigProfileTests: XCTestCase { "Set AGENTDOCK_INSTALLED_CONFIG_PROFILE_HOME and AGENTDOCK_INSTALLED_CONFIG_PROFILE_NAME to validate an installed profile." ) } - let executableURL = CodexBundledCLI.executableURL( - for: URL(fileURLWithPath: "/Applications/Codex.app") - ) + let appURL = installedProviderAppURL(.codex) + try OfficialCodexAppValidator().validateCodexApp(at: appURL) + let executableURL = CodexBundledCLI.executableURL(for: appURL) let profile = try CodexConfigProfile(validating: profileName) let overrides = try profile.appServerConfigOverrides( in: URL(fileURLWithPath: homePath, isDirectory: true) diff --git a/Tests/CodexerCoreTests/CodexLauncherTests.swift b/Tests/CodexerCoreTests/CodexLauncherTests.swift index 0060210..a119efd 100644 --- a/Tests/CodexerCoreTests/CodexLauncherTests.swift +++ b/Tests/CodexerCoreTests/CodexLauncherTests.swift @@ -939,7 +939,7 @@ final class CodexLauncherTests: XCTestCase { throw XCTSkip("Set AGENTDOCK_INSTALLED_APP_TEST=1 to validate the installed Codex app.") } try OfficialCodexAppValidator().validateCodexApp( - at: URL(fileURLWithPath: "/Applications/Codex.app") + at: installedProviderAppURL(.codex) ) } @@ -1011,7 +1011,7 @@ final class CodexLauncherTests: XCTestCase { mcpOAuthCallbackPort: CodexMCPConfiguration.managedCallbackPorts.lowerBound ) try prepareIsolationLayout(for: profile) - let appURL = URL(fileURLWithPath: "/Applications/Codex.app") + let appURL = installedProviderAppURL(.codex) let controller = CodexInstanceController() let stockProcessIDs = Set( NSRunningApplication.runningApplications( @@ -1090,7 +1090,7 @@ final class CodexLauncherTests: XCTestCase { profileDirectory: profileDirectory, shortcutDirectory: FileManager.default.temporaryDirectory ) - let appURL = URL(fileURLWithPath: "/Applications/Codex.app") + let appURL = installedProviderAppURL(.codex) let controller = CodexInstanceController() let running = try await controller.status(for: profile, codexAppURL: appURL) guard running.isRunning else { diff --git a/Tests/CodexerCoreTests/CodexMCPConfigurationTests.swift b/Tests/CodexerCoreTests/CodexMCPConfigurationTests.swift index 97d9ba6..36a9cd2 100644 --- a/Tests/CodexerCoreTests/CodexMCPConfigurationTests.swift +++ b/Tests/CodexerCoreTests/CodexMCPConfigurationTests.swift @@ -3,7 +3,7 @@ import XCTest final class CodexMCPConfigurationTests: XCTestCase { func testInstalledCodexCLIUsesSupportedBundleLayout() throws { - let appURL = URL(fileURLWithPath: "/Applications/Codex.app") + let appURL = installedProviderAppURL(.codex) guard FileManager.default.fileExists(atPath: appURL.path) else { throw XCTSkip("Codex.app is not installed") } @@ -55,7 +55,7 @@ final class CodexMCPConfigurationTests: XCTestCase { guard ProcessInfo.processInfo.environment["AGENTDOCK_INSTALLED_APP_TEST"] == "1" else { throw XCTSkip("Set AGENTDOCK_INSTALLED_APP_TEST=1 to validate the installed Codex CLI.") } - let appURL = URL(fileURLWithPath: "/Applications/Codex.app") + let appURL = installedProviderAppURL(.codex) try OfficialCodexAppValidator().validateCodexApp(at: appURL) let home = FileManager.default.temporaryDirectory .appendingPathComponent("CodexMCPInstalled-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/CodexerCoreTests/InstalledProviderTestSupport.swift b/Tests/CodexerCoreTests/InstalledProviderTestSupport.swift new file mode 100644 index 0000000..d00f6da --- /dev/null +++ b/Tests/CodexerCoreTests/InstalledProviderTestSupport.swift @@ -0,0 +1,10 @@ +import Foundation +@testable import CodexerCore + +func installedProviderAppURL(_ product: DesktopProduct) -> URL { + let key = product == .codex ? "AGENTDOCK_CODEX_APP_PATH" : "AGENTDOCK_CLAUDE_APP_PATH" + if let path = ProcessInfo.processInfo.environment[key], !path.isEmpty { + return URL(fileURLWithPath: path, isDirectory: true) + } + return DesktopAppRegistry.descriptor(for: product).defaultAppURL +} diff --git a/Tests/CodexerCoreTests/LiveProfileIsolationTests.swift b/Tests/CodexerCoreTests/LiveProfileIsolationTests.swift index 0b5764f..7120ebc 100644 --- a/Tests/CodexerCoreTests/LiveProfileIsolationTests.swift +++ b/Tests/CodexerCoreTests/LiveProfileIsolationTests.swift @@ -16,7 +16,7 @@ final class LiveProfileIsolationTests: XCTestCase { throw XCTSkip("Set AGENTDOCK_LIVE_ISOLATION_TEST=1 to open two temporary provider profiles.") } let descriptor = DesktopAppRegistry.descriptor(for: product) - let appURL = descriptor.defaultAppURL + let appURL = installedProviderAppURL(product) let controller = DesktopInstanceController() try await controller.validateApp(product: product, at: appURL) let root = FileManager.default.temporaryDirectory @@ -214,7 +214,8 @@ final class LiveProfileIsolationTests: XCTestCase { .standardizedFileURL.resolvingSymlinksInPath() let paths = ClaudeInstanceDiscovery.userDataPathsByMainProcess( in: snapshot, - appURL: appURL + appURL: appURL, + trustedHelperProcessIDs: Set(snapshot.entries.keys) )[processID] ?? [] let selectedPath = profile.claudeUserDataPath.standardizedFileURL.resolvingSymlinksInPath().path let command = snapshot.entries[processID]?.command diff --git a/docs/architecture.md b/docs/architecture.md index 6a953dd..4598b5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,13 @@ separates them. AgentDock maps a managed profile path to an exact signed main-app process. Focus and close operations revalidate the bundle, executable, PID, ancestry, -and profile argument immediately before acting. Codex close also recognizes +and profile argument immediately before acting. Claude discovery admits only +publisher-signed helpers whose kernel-resolved executable is contained by the +selected app bundle, and binds lifecycle actions to stable process start +identity. Claude checks its startup contract at the start of each operation; +launch and close polling refresh process trust without repeatedly scanning the +ASAR archive. The app signature is checked again immediately before launch. +Codex close also recognizes signed provider helpers whose executable is contained by that profile's `CODEX_HOME`, including helpers that no longer carry an Electron profile argument. Stock app discovery is separate, so a managed action does not target diff --git a/docs/development.md b/docs/development.md index 28b2e37..3131c21 100644 --- a/docs/development.md +++ b/docs/development.md @@ -141,7 +141,7 @@ main process identity fails the check. Installed-app checks: ```bash -AGENTDOCK_INSTALLED_APP_TEST=1 swift test --filter 'testOfficialInstalledCodexSignatureIsAccepted|testInstalledCodexAcceptsIsolatedMCPConfigurationWhenEnabled' +AGENTDOCK_INSTALLED_APP_TEST=1 swift test --filter 'testOfficialInstalledCodexSignatureIsAccepted|testInstalledCodexAcceptsIsolatedMCPConfigurationWhenEnabled|testInstalledAppServerRespondsFromAnIsolatedHomeWhenEnabled' AGENTDOCK_LIVE_LIFECYCLE=1 swift test \ --filter CodexLauncherTests/testLiveProfileCanOpenAndCloseWithoutTouchingStockInstance @@ -158,6 +158,11 @@ AGENTDOCK_CLAUDE_LIVE_LIFECYCLE=1 swift test \ The installed Codex configuration check validates the signed app and runs its bundled CLI against a temporary managed MCP configuration without account data. +The app-server check initializes the real bundled executable and sends a usage +request from a fresh temporary home, expecting the unauthenticated response. +Set `AGENTDOCK_CODEX_APP_PATH` or `AGENTDOCK_CLAUDE_APP_PATH` to test an alternate +signed bundle in the installed-app and live lifecycle/isolation checks. These +overrides leave the normally installed app in place. Repeat it after provider updates. Both installed-profile checks and runtime launches resolve the current nested CLI bundle with a legacy fallback. The separate renderer showcase omits Codex accounting-only `token_usage_record` diff --git a/docs/resource-usage-readers.md b/docs/resource-usage-readers.md index 4627678..9ca67f6 100644 --- a/docs/resource-usage-readers.md +++ b/docs/resource-usage-readers.md @@ -36,6 +36,10 @@ therefore reuse an explicitly dated measurement. The storage cache retains at most 64 roots. Entry, depth, time, and cancellation limits preserve lower-bound semantics through `dataSizeIsTruncated`. +Staleness checks schedule local stats and provider quotas independently. A quota +retry does not rescan fresh local stats, and stale local stats do not bypass the +one-minute quota retry backoff. + Regression tests use synthetic data in real temporary files. They cover large histories, unchanged reuse, append/rewrite/replacement invalidation, symlink rejection, cache eviction across separate accounts, partial coverage, separate diff --git a/docs/security.md b/docs/security.md index 21b04db..e049154 100644 --- a/docs/security.md +++ b/docs/security.md @@ -88,7 +88,9 @@ replayed as profile deletions. Persisted shortcuts must be distinct `.app` paths whose normalized name matches the owning profile slug. Recovery journals cannot claim another profile's directory or shortcut. Process discovery rejects ambiguous profile arguments and bundle-prefix -lookalikes before lifecycle operations revalidate the process identity. Profile +lookalikes. Claude helper evidence also requires a publisher signature and a +kernel-resolved executable contained by the selected app bundle before +lifecycle operations revalidate stable process identity. Profile locks resolve path aliases to one physical root and reject symlinked, nonregular, multiply linked, or foreign-owned lock files.