diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f15422743..3efa201912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ ### Fixed - Usage refresh: refresh provider data shortly after known quota reset boundaries instead of leaving expired reset times visible until the next normal poll. Thanks @pavbar! +- Claude: block background delegated CLI OAuth refresh when the keychain holds MCP-only state (`mcpOAuth` without `claudeAiOauth`) while preserving explicit Refresh recovery (#1844). Thanks @Yuxin-Qiao! - Settings: align General-pane controls, show compact installed terminal app icons, and enlarge the window to fit more options. - Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251! - Cursor: hide misleading pace and run-out details once a billing-cycle quota is fully depleted. Thanks @Yuxin-Qiao! diff --git a/Scripts/verify_1844_live.sh b/Scripts/verify_1844_live.sh new file mode 100755 index 0000000000..61bd283aaf --- /dev/null +++ b/Scripts/verify_1844_live.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# Isolated live verification for CodexBar #1844 / PR #1848. +# Uses only synthetic credentials under a disposable HOME and keychain. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +log() { printf '[verify-1844] %s\n' "$*"; } + +ARTIFACT="$(mktemp -d "${TMPDIR:-/tmp}/codexbar-1844-verify.XXXXXX")" +chmod 700 "$ARTIFACT" +HOME_FIXTURE="$ARTIFACT/home" +KEYCHAIN="$ARTIFACT/claude-fixture.keychain-db" +KEYCHAIN_PASSWORD="codexbar-1844-synthetic-fixture" +CONFIG="$ARTIFACT/config.json" +CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}" +APP="${CODEXBAR_APP_BINARY:-$ROOT/CodexBar.app/Contents/MacOS/CodexBar}" +MCP_PAYLOAD='{"mcpOAuth":{"plugin:synthetic":{"accessToken":"synthetic-mcp-token"}}}' +EXPIRED_PAYLOAD='{"claudeAiOauth":{"accessToken":"synthetic-expired-token","expiresAt":1000,"scopes":["user:profile"],"refreshToken":"synthetic-refresh-token"}}' + +if [[ ! -x "$CLI" ]]; then + log "Missing packaged CLI: $CLI" + log "Run ./Scripts/package_app.sh, then retry." + exit 2 +fi +if [[ ! -x "$APP" ]]; then + log "Missing packaged app binary: $APP" + exit 2 +fi + +cleanup() { + /usr/bin/security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +log "Artifacts: $ARTIFACT" +log "Phase 1: focused integration tests" +{ + swift test --filter ClaudeOAuthTests + swift test --filter ClaudeUsageTests + swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests + swift test --filter 'expired claude CLI owner blocks background' + swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests + swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests + swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests +} 2>&1 | tee "$ARTIFACT/integration-tests.log" +log "Phase 1 passed" + +log "Phase 2: disposable HOME, keychain, credentials, config, and Claude CLI canary" +mkdir -p "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin" +chmod 700 "$HOME_FIXTURE" "$HOME_FIXTURE/.claude" "$HOME_FIXTURE/Library" \ + "$HOME_FIXTURE/Library/Preferences" "$ARTIFACT/bin" +printf '%s\n' "$EXPIRED_PAYLOAD" >"$HOME_FIXTURE/.claude/.credentials.json" +chmod 600 "$HOME_FIXTURE/.claude/.credentials.json" +printf '%s\n' '{"version":1,"providers":[{"id":"claude","enabled":true,"source":"oauth"}]}' >"$CONFIG" +chmod 600 "$CONFIG" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "args:" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'printf " %q" "$@" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'printf "\\n" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + 'if [[ "$*" == "auth status --json" ]]; then printf "{\"loggedIn\":true}\\n"; exit 0; fi' \ + 'if [[ "$*" == "--version" ]]; then printf "2.1.0\\n"; exit 0; fi' \ + 'if IFS= read -r line; then' \ + ' printf "stdin:%s\\n" "$line" >>"$CODEXBAR_CLAUDE_INVOCATIONS"' \ + ' if [[ "$line" == *"/status"* ]]; then printf touched >"$CODEXBAR_CLAUDE_TOUCH_CANARY"; fi' \ + 'fi' \ + 'exit 99' \ + >"$ARTIFACT/bin/claude" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf touched >"$CODEXBAR_OPEN_TOUCH_CANARY"' \ + 'exit 99' \ + >"$ARTIFACT/bin/open" +chmod 700 "$ARTIFACT/bin/claude" "$ARTIFACT/bin/open" + +/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-before.txt" +/usr/bin/security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +/usr/bin/security set-keychain-settings -t 3600 "$KEYCHAIN" +/usr/bin/security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" +/usr/bin/security add-generic-password \ + -a codexbar-verify-1844 \ + -s 'Claude Code-credentials' \ + -w "$MCP_PAYLOAD" \ + -A \ + "$KEYCHAIN" +/usr/bin/security list-keychains -d user >"$ARTIFACT/keychains-after.txt" +if ! cmp -s "$ARTIFACT/keychains-before.txt" "$ARTIFACT/keychains-after.txt"; then + log "Phase 2 failed: creating the disposable keychain changed the user search list" + exit 1 +fi +/usr/bin/security find-generic-password \ + -s 'Claude Code-credentials' \ + -w \ + "$KEYCHAIN" >"$ARTIFACT/keychain-fixture.json" +cmp -s "$ARTIFACT/keychain-fixture.json" <(printf '%s\n' "$MCP_PAYLOAD") + +PROC_LOG="$ARTIFACT/e2e-processes.log" +STDOUT="$ARTIFACT/e2e-stdout.json" +STDERR="$ARTIFACT/e2e-stderr.jsonl" +CANARY="$ARTIFACT/claude-status-canary" +INVOCATIONS="$ARTIFACT/claude-invocations.log" +OPEN_CANARY="$ARTIFACT/open-touch-canary" +: >"$PROC_LOG" +: >"$INVOCATIONS" + +set +e +( + env \ + HOME="$HOME_FIXTURE" \ + CFFIXED_USER_HOME="$HOME_FIXTURE" \ + CODEXBAR_CONFIG="$CONFIG" \ + CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \ + CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \ + CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \ + CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \ + CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \ + CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \ + CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \ + PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$CLI" usage --provider claude --source oauth --format json --pretty --log-level debug \ + >"$STDOUT" 2>"$STDERR" +) & +PID=$! +while kill -0 "$PID" 2>/dev/null; do + { + date -u +%H:%M:%S + pgrep -P "$PID" -l 2>/dev/null || true + } >>"$PROC_LOG" + sleep 0.02 +done +wait "$PID" +CLI_STATUS=$? +set -e + +{ + echo "# CodexBar #1844 isolated E2E verification" + echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "candidate: $(git rev-parse HEAD)" + echo "packaged-cli: $CLI" + echo "cli-exit: $CLI_STATUS" + echo "default-keychain-search-list-unchanged: yes" + echo "real-home-referenced: no" + echo "claude-status-canary: $([[ -e "$CANARY" ]] && echo touched || echo untouched)" + echo "open-touch-canary: $([[ -e "$OPEN_CANARY" ]] && echo touched || echo untouched)" + echo + echo "## stdout" + cat "$STDOUT" + echo + echo "## stderr (filtered)" + rg -i 'mcp|delegated|expired|oauth|touch|open|only prompt|user action' "$STDERR" || true + echo + echo "## Claude CLI invocations" + cat "$INVOCATIONS" + echo + echo "## child processes" + cat "$PROC_LOG" +} | tee "$ARTIFACT/E2E-REPORT.md" + +if [[ "$CLI_STATUS" -eq 0 ]]; then + log "Phase 2 failed: the MCP-only fixture unexpectedly produced successful OAuth usage" + exit 1 +fi +if [[ -e "$CANARY" ]]; then + log "Phase 2 failed: delegated Claude CLI /status touch ran" + exit 1 +fi +if [[ -e "$OPEN_CANARY" ]]; then + log "Phase 2 failed: browser/open helper ran" + exit 1 +fi +if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$PROC_LOG" 2>/dev/null; then + log "Phase 2 failed: an open helper or browser was a probe child" + exit 1 +fi +if ! rg -qi 'MCP OAuth state only|mcpOAuthOnlyKeychain|MCP OAuth' "$STDERR" "$STDOUT"; then + log "Phase 2 failed: expected MCP-only fail-closed message not found" + exit 1 +fi + +log "Phase 2 passed: exact packaged CLI failed closed without delegated /status touch or browser child" + +log "Phase 3: isolated packaged app runtime smoke" +APP_PROC_LOG="$ARTIFACT/app-processes.log" +APP_STDOUT="$ARTIFACT/app-stdout.log" +APP_STDERR="$ARTIFACT/app-stderr.log" +: >"$APP_PROC_LOG" +: >"$INVOCATIONS" +( + env \ + HOME="$HOME_FIXTURE" \ + CFFIXED_USER_HOME="$HOME_FIXTURE" \ + CODEXBAR_CONFIG="$CONFIG" \ + CODEXBAR_DISABLE_KEYCHAIN_ACCESS=1 \ + CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN="$KEYCHAIN" \ + CODEXBAR_CLAUDE_TOUCH_CANARY="$CANARY" \ + CODEXBAR_CLAUDE_INVOCATIONS="$INVOCATIONS" \ + CODEXBAR_OPEN_TOUCH_CANARY="$OPEN_CANARY" \ + CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW=1 \ + CLAUDE_CLI_PATH="$ARTIFACT/bin/claude" \ + PATH="$ARTIFACT/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$APP" >"$APP_STDOUT" 2>"$APP_STDERR" +) & +APP_PID=$! +APP_OBSERVED_CLI=0 +POST_DISCOVERY_TICKS=0 +for _ in $(seq 1 1000); do + if ! kill -0 "$APP_PID" 2>/dev/null; then + log "Phase 3 failed: packaged app exited before the isolated startup smoke completed" + wait "$APP_PID" || true + exit 1 + fi + { + date -u +%H:%M:%S + pgrep -P "$APP_PID" -l 2>/dev/null || true + } >>"$APP_PROC_LOG" + if rg -q '^args: --version$' "$INVOCATIONS"; then + APP_OBSERVED_CLI=1 + POST_DISCOVERY_TICKS=$((POST_DISCOVERY_TICKS + 1)) + if [[ "$POST_DISCOVERY_TICKS" -ge 250 ]]; then + break + fi + fi + sleep 0.02 +done +kill "$APP_PID" +wait "$APP_PID" 2>/dev/null || true + +if [[ "$APP_OBSERVED_CLI" -ne 1 ]]; then + log "Phase 3 failed: packaged app never exercised the isolated Claude CLI fixture" + exit 1 +fi +if [[ -e "$CANARY" ]]; then + log "Phase 3 failed: packaged app invoked delegated Claude CLI /status touch" + exit 1 +fi +if [[ -e "$OPEN_CANARY" ]]; then + log "Phase 3 failed: packaged app invoked browser/open helper" + exit 1 +fi +if rg -q '/usr/bin/open|(^|/)open$|firefox|Google Chrome|Safari' "$APP_PROC_LOG" 2>/dev/null; then + log "Phase 3 failed: an open helper or browser was an app child" + exit 1 +fi +{ + echo + echo "## packaged app runtime" + echo "app-binary: $APP" + echo "isolated-claude-cli-discovery-observed: yes" + echo "post-discovery-observation-seconds: 5" + echo "app-stayed-running: yes" + echo "claude-status-canary: untouched" + echo "open-touch-canary: untouched" + echo "browser-child: none" + echo + echo "## packaged app Claude CLI invocations" + cat "$INVOCATIONS" +} | tee -a "$ARTIFACT/E2E-REPORT.md" + +log "Phase 3 passed: packaged app exercised CLI discovery without delegated /status touch or browser child" +log "Report: $ARTIFACT/E2E-REPORT.md" diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 0e44c5f0fe..9bb848d4b3 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -5,6 +5,7 @@ import SweetCookieKit public enum KeychainAccessGate { private static let flagKey = "debugDisableKeychainAccess" + static let disableAccessEnvironmentKey = "CODEXBAR_DISABLE_KEYCHAIN_ACCESS" @TaskLocal private static var taskOverrideValue: Bool? private nonisolated(unsafe) static var overrideValue: Bool? private static let processForceDisabledLock = NSLock() @@ -13,6 +14,7 @@ public enum KeychainAccessGate { public nonisolated(unsafe) static var isDisabled: Bool { get { if let taskOverrideValue { return taskOverrideValue } + if self.isDisabledByEnvironment() { return true } #if DEBUG if Self.forcesDisabledUnderTests { return true @@ -34,6 +36,12 @@ public enum KeychainAccessGate { } } + static func isDisabledByEnvironment( + _ environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + environment[self.disableAccessEnvironmentKey] == "1" + } + public static func forceDisabledForProcess(reason: String) { self.processForceDisabledLock.lock() self.processForceDisabledReason = reason diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift index f860a77570..490a1ae333 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift @@ -87,7 +87,18 @@ public struct ClaudeOAuthCredentials: Sendable { return normalized } + public static func isMcpOAuthOnlyPayload(data: Data) -> Bool { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return json["claudeAiOauth"] == nil && json["mcpOAuth"] != nil + } + public static func parse(data: Data) throws -> ClaudeOAuthCredentials { + if ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: data) { + throw ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain + } + let decoder = JSONDecoder() guard let root = try? decoder.decode(Root.self, from: data) else { throw ClaudeOAuthCredentialsError.decodeFailed @@ -215,6 +226,7 @@ public struct ClaudeOAuthCredentialRecord: Sendable { public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { case decodeFailed case missingOAuth + case mcpOAuthOnlyKeychain case missingAccessToken case notFound case keychainError(Int) @@ -229,6 +241,11 @@ public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { return "Claude OAuth credentials are invalid." case .missingOAuth: return "Claude OAuth credentials missing. Run `claude` to authenticate." + case .mcpOAuthOnlyKeychain: + return "Claude keychain contains MCP OAuth state only (no claudeAiOauth). " + + "Claude Code may store subscription OAuth elsewhere now. " + + "Open the CodexBar menu and click Refresh to re-authenticate, " + + "or switch Claude Usage source to Web/CLI." case .missingAccessToken: return "Claude OAuth access token missing. Run `claude` to authenticate." case .notFound: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 1789258e34..60708efdad 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -12,6 +12,7 @@ import Musl extension ClaudeOAuthCredentialsStore { private static let securityBinaryPath = "/usr/bin/security" private static let securityCLIReadTimeout: TimeInterval = 1.5 + static let isolatedSecurityCLIKeychainEnvironmentKey = "CODEXBAR_CLAUDE_SECURITY_CLI_KEYCHAIN" struct SecurityCLIReadRequest { let account: String? @@ -27,6 +28,7 @@ extension ClaudeOAuthCredentialsStore { #if os(macOS) private enum SecurityCLIReadError: Error { case binaryUnavailable + case isolatedKeychainUnavailable case launchFailed case timedOut case nonZeroExit(status: Int32, stderrLength: Int) @@ -45,6 +47,49 @@ extension ClaudeOAuthCredentialsStore { interaction: ProviderInteraction, readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) -> Data? + { + guard let sanitized = self.readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( + interaction: interaction, + readStrategy: readStrategy) + else { + return nil + } + + let interactionMetadata = interaction == .userInitiated ? "user" : "background" + let parsedCredentials: ClaudeOAuthCredentials + do { + parsedCredentials = try ClaudeOAuthCredentials.parse(data: sanitized) + } catch { + self.log.warning( + "Claude keychain security CLI output invalid; falling back", + metadata: [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "payload_bytes": "\(sanitized.count)", + "parse_error_type": String(describing: type(of: error)), + ]) + return nil + } + + var metadata: [String: String] = [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "payload_bytes": "\(sanitized.count)", + ] + for (key, value) in parsedCredentials.diagnosticsMetadata(now: Date()) { + metadata[key] = value + } + self.log.debug( + "Claude keychain security CLI read succeeded", + metadata: metadata) + return sanitized + } + + static func readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( + interaction: ProviderInteraction, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current(), + environment: [String: String] = ProcessInfo.processInfo.environment) + -> Data? { guard self.shouldPreferSecurityCLIKeychainRead(readStrategy: readStrategy) else { return nil } let interactionMetadata = interaction == .userInitiated ? "user" : "background" @@ -77,7 +122,8 @@ extension ClaudeOAuthCredentialsStore { } else { let result = try self.runClaudeSecurityCLIRead( timeout: self.securityCLIReadTimeout, - account: preferredAccount) + account: preferredAccount, + environment: environment) output = result.stdout status = result.status stderrLength = result.stderrLength @@ -86,7 +132,8 @@ extension ClaudeOAuthCredentialsStore { #else let result = try self.runClaudeSecurityCLIRead( timeout: self.securityCLIReadTimeout, - account: preferredAccount) + account: preferredAccount, + environment: environment) output = result.stdout status = result.status stderrLength = result.stderrLength @@ -95,12 +142,20 @@ extension ClaudeOAuthCredentialsStore { let sanitized = self.sanitizeSecurityCLIOutput(output) guard !sanitized.isEmpty else { return nil } - let parsedCredentials: ClaudeOAuthCredentials - do { - parsedCredentials = try ClaudeOAuthCredentials.parse(data: sanitized) - } catch { + if ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: sanitized) { self.log.warning( - "Claude keychain security CLI output invalid; falling back", + "Claude keychain security CLI output is MCP OAuth only; falling back", + metadata: [ + "reader": "securityCLI", + "callerInteraction": interactionMetadata, + "status": "\(status)", + "duration_ms": String(format: "%.2f", durationMs), + "stderr_length": "\(stderrLength)", + "payload_bytes": "\(sanitized.count)", + ]) + } else { + self.log.debug( + "Claude keychain security CLI raw read succeeded", metadata: [ "reader": "securityCLI", "callerInteraction": interactionMetadata, @@ -108,26 +163,8 @@ extension ClaudeOAuthCredentialsStore { "duration_ms": String(format: "%.2f", durationMs), "stderr_length": "\(stderrLength)", "payload_bytes": "\(sanitized.count)", - "parse_error_type": String(describing: type(of: error)), ]) - return nil - } - - var metadata: [String: String] = [ - "reader": "securityCLI", - "callerInteraction": interactionMetadata, - "status": "\(status)", - "duration_ms": String(format: "%.2f", durationMs), - "stderr_length": "\(stderrLength)", - "payload_bytes": "\(sanitized.count)", - "accountPinned": preferredAccount == nil ? "0" : "1", - ] - for (key, value) in parsedCredentials.diagnosticsMetadata(now: Date()) { - metadata[key] = value } - self.log.debug( - "Claude keychain security CLI read succeeded", - metadata: metadata) return sanitized } catch let error as SecurityCLIReadError { var metadata: [String: String] = [ @@ -138,6 +175,8 @@ extension ClaudeOAuthCredentialsStore { switch error { case .binaryUnavailable: metadata["reason"] = "binaryUnavailable" + case .isolatedKeychainUnavailable: + metadata["reason"] = "isolatedKeychainUnavailable" case .launchFailed: metadata["reason"] = "launchFailed" case .timedOut: @@ -171,21 +210,15 @@ extension ClaudeOAuthCredentialsStore { private static func runClaudeSecurityCLIRead( timeout: TimeInterval, - account: String?) throws -> SecurityCLIReadCommandResult + account: String?, + environment: [String: String]) throws -> SecurityCLIReadCommandResult { guard FileManager.default.isExecutableFile(atPath: self.securityBinaryPath) else { throw SecurityCLIReadError.binaryUnavailable } - - var arguments = [ - "find-generic-password", - "-s", - self.claudeKeychainService, - ] - if let account, !account.isEmpty { - arguments.append(contentsOf: ["-a", account]) + guard let arguments = self.securityCLIReadArguments(account: account, environment: environment) else { + throw SecurityCLIReadError.isolatedKeychainUnavailable } - arguments.append("-w") let process = Process() process.executableURL = URL(fileURLWithPath: self.securityBinaryPath) @@ -235,6 +268,31 @@ extension ClaudeOAuthCredentialsStore { durationMs: durationMs) } + static func securityCLIReadArguments( + account: String?, + environment: [String: String]) -> [String]? + { + var arguments = [ + "find-generic-password", + "-s", + self.claudeKeychainService, + ] + if let account, !account.isEmpty { + arguments.append(contentsOf: ["-a", account]) + } + arguments.append("-w") + + let rawIsolatedPath = environment[self.isolatedSecurityCLIKeychainEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + if rawIsolatedPath != nil || KeychainAccessGate.isDisabledByEnvironment(environment) { + guard let isolatedPath = self.isolatedSecurityCLIKeychainPath(environment: environment) else { + return nil + } + arguments.append(isolatedPath) + } + return arguments + } + private static func terminate(process: Process, processGroup: pid_t?) { guard process.isRunning else { return } process.terminate() @@ -260,5 +318,48 @@ extension ClaudeOAuthCredentialsStore { { nil } + + static func readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( + interaction _: ProviderInteraction, + readStrategy _: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current(), + environment _: [String: String] = ProcessInfo.processInfo.environment) + -> Data? + { + nil + } #endif + + private static func isolatedSecurityCLIKeychainPath(environment: [String: String]) -> String? { + guard KeychainAccessGate.isDisabledByEnvironment(environment) else { return nil } + guard let rawPath = environment[self.isolatedSecurityCLIKeychainEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawPath.isEmpty, + (rawPath as NSString).isAbsolutePath + else { + return nil + } + return URL(fileURLWithPath: rawPath).standardizedFileURL.path + } + + static func isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: ProviderInteraction, + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current(), + keychainAccessDisabled: Bool = KeychainAccessGate.isDisabled, + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + guard !keychainAccessDisabled || self.isolatedSecurityCLIKeychainPath(environment: environment) != nil else { + return false + } + let payload: Data? = switch readStrategy { + case .securityFramework: + self.readRawClaudeKeychainPayloadViaSecurityFrameworkWithoutPrompt() + case .securityCLIExperimental: + self.readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( + interaction: interaction, + readStrategy: readStrategy, + environment: environment) + } + guard let payload else { return false } + return ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: payload) + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index e311bda15e..ebe55b51b5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1174,6 +1174,16 @@ public enum ClaudeOAuthCredentialsStore { switch record.owner { case .claudeCLI: + if ProviderInteractionContext.current != .userInitiated, + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: ProviderInteractionContext.current, + environment: environment) + { + self.log.warning( + "Claude OAuth credentials expired; Claude keychain has MCP OAuth state only", + metadata: expiryMetadata) + throw ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain + } self.log.info( "Claude OAuth credentials expired; delegating refresh to Claude CLI", metadata: expiryMetadata) @@ -1625,11 +1635,16 @@ public enum ClaudeOAuthCredentialsStore { return "\(modifiedAt):\(fingerprint.size)" } - private static func loadFromClaudeKeychainNonInteractive() throws -> Data? { + private static func loadFromClaudeKeychainNonInteractive( + readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) + throws -> Data? + { #if os(macOS) - let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() + let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode( + readStrategy: readStrategy) if let data = self.loadFromClaudeKeychainViaSecurityCLIIfEnabled( - interaction: ProviderInteractionContext.current) + interaction: ProviderInteractionContext.current, + readStrategy: readStrategy) { return data } @@ -1664,6 +1679,38 @@ public enum ClaudeOAuthCredentialsStore { #endif } + static func readRawClaudeKeychainPayloadViaSecurityFrameworkWithoutPrompt() -> Data? { + #if os(macOS) + guard self.keychainAccessAllowed else { return nil } + #if DEBUG + if let store = self.taskClaudeKeychainOverrideStore { return store.data } + if let override = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride { return override } + #endif + + // This probe must work under the default `onlyOnUserAction` policy, but must never show Keychain UI. + // The candidate and data queries both use KeychainNoUIQuery; `.always` only bypasses the prompt-policy gate. + switch self.claudeKeychainCandidatesProbeWithoutPrompt( + promptMode: .always, + enforcePromptPolicy: false) + { + case .unavailable: + return nil + case let .value(candidates): + if let newest = candidates.first { + return try? self.loadClaudeKeychainData( + candidate: newest, + allowKeychainPrompt: false, + promptMode: .always) + } + } + return try? self.loadClaudeKeychainLegacyData( + allowKeychainPrompt: false, + promptMode: .always) + #else + return nil + #endif + } + public static func loadFromClaudeKeychain() throws -> Data { guard self.shouldAllowClaudeCodeKeychainAccess(mode: ClaudeOAuthKeychainPromptPreference.current()) else { throw ClaudeOAuthCredentialsError.notFound diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index d775e584fe..15a5c42bcf 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -8,6 +8,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { var lastAttemptAt: Date? var lastCooldownInterval: TimeInterval? var inFlightAttemptID: UInt64? + var inFlightInteraction: ProviderInteraction? var inFlightTask: Task? var nextAttemptID: UInt64 = 0 @@ -40,9 +41,29 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { return .attemptedFailed("Cancelled.") } - switch self.inFlightDecision(now: now, timeout: timeout, environment: environment) { + let decision = self.inFlightDecision( + now: now, + timeout: timeout, + environment: environment, + interaction: ProviderInteractionContext.current) + #if DEBUG + if case .joinThenRetry = decision { + self.userInitiatedBackgroundJoinObserverForTesting?() + } + #endif + + switch decision { case let .join(task): return await task.value + case let .joinThenRetry(id, task, state): + let outcome = await task.value + self.clearInFlightTaskIfStillCurrent(id: id, state: state) + switch outcome { + case .attemptedFailed, .skippedByCooldown, .cliUnavailable: + return await self.attempt(now: now, timeout: timeout, environment: environment) + case .attemptedSucceeded: + return outcome + } case let .start(id, task, state): let outcome = await task.value self.clearInFlightTaskIfStillCurrent(id: id, state: state) @@ -52,11 +73,13 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private enum InFlightDecision { case join(Task) + case joinThenRetry(UInt64, Task, AttemptStateStorage) case start(UInt64, Task, AttemptStateStorage) } private struct AttemptConfiguration { let environment: [String: String] + let interaction: ProviderInteraction let readStrategy: ClaudeOAuthKeychainReadStrategy let keychainAccessDisabled: Bool #if DEBUG @@ -69,13 +92,20 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { private static func inFlightDecision( now: Date, timeout: TimeInterval, - environment: [String: String]) -> InFlightDecision + environment: [String: String], + interaction: ProviderInteraction) -> InFlightDecision { let state = self.currentStateStorage state.lock.lock() defer { state.lock.unlock() } if let existing = state.inFlightTask { + if interaction == .userInitiated, + state.inFlightInteraction != .userInitiated, + let existingID = state.inFlightAttemptID + { + return .joinThenRetry(existingID, existing, state) + } return .join(existing) } @@ -85,6 +115,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { #if DEBUG let configuration = AttemptConfiguration( environment: environment, + interaction: interaction, readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(), keychainAccessDisabled: KeychainAccessGate.isDisabled, cliAvailableOverride: self.cliAvailableOverrideForTesting, @@ -94,6 +125,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { #else let configuration = AttemptConfiguration( environment: environment, + interaction: interaction, readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(), keychainAccessDisabled: KeychainAccessGate.isDisabled) #endif @@ -115,6 +147,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { #endif } state.inFlightAttemptID = attemptID + state.inFlightInteraction = interaction state.inFlightTask = task return .start(attemptID, task, state) } @@ -132,11 +165,28 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { // Atomically reserve an attempt under the lock so concurrent callers don't race past isInCooldown() and start // multiple touches/poll loops. - guard self.reserveAttemptIfNotInCooldown(now: now, state: state) else { + guard self.reserveAttemptIfNotInCooldown( + now: now, + bypassCooldown: configuration.interaction == .userInitiated, + state: state) + else { self.log.debug("Claude OAuth delegated refresh skipped by cooldown") return .skippedByCooldown } + if let mcpOAuthOnlyFailure = self.mcpOAuthOnlyKeychainFailureIfPresent( + interaction: configuration.interaction, + readStrategy: configuration.readStrategy, + keychainAccessDisabled: configuration.keychainAccessDisabled, + environment: configuration.environment) + { + self.recordAttempt(now: now, cooldown: self.defaultCooldownInterval, state: state) + self.log.warning( + "Claude OAuth delegated refresh skipped: Claude keychain has MCP OAuth state only", + metadata: ["readStrategy": configuration.readStrategy.rawValue]) + return .attemptedFailed(mcpOAuthOnlyFailure) + } + let baseline = self.currentKeychainChangeObservationBaseline( readStrategy: configuration.readStrategy, keychainAccessDisabled: configuration.keychainAccessDisabled, @@ -358,15 +408,35 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { interaction: ProviderInteraction) -> Data? { guard !keychainAccessDisabled else { return nil } - return ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled( + return ClaudeOAuthCredentialsStore.readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled( interaction: interaction, readStrategy: readStrategy) } + private static func mcpOAuthOnlyKeychainFailureIfPresent( + interaction: ProviderInteraction, + readStrategy: ClaudeOAuthKeychainReadStrategy, + keychainAccessDisabled: Bool, + environment: [String: String]) -> String? + { + guard interaction != .userInitiated else { return nil } + guard ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: interaction, + readStrategy: readStrategy, + keychainAccessDisabled: keychainAccessDisabled, + environment: environment) + else { + return nil + } + return ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain.errorDescription + ?? "Claude keychain contains MCP OAuth state only." + } + private static func clearInFlightTaskIfStillCurrent(id: UInt64, state: AttemptStateStorage) { state.lock.lock() if state.inFlightAttemptID == id { state.inFlightAttemptID = nil + state.inFlightInteraction = nil state.inFlightTask = nil } state.lock.unlock() @@ -383,13 +453,20 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { UserDefaults.standard.set(cooldown, forKey: self.cooldownIntervalDefaultsKey) } - private static func reserveAttemptIfNotInCooldown(now: Date, state: AttemptStateStorage) -> Bool { + private static func reserveAttemptIfNotInCooldown( + now: Date, + bypassCooldown: Bool, + state: AttemptStateStorage) -> Bool + { state.lock.lock() defer { state.lock.unlock() } self.loadStateIfNeededLocked(state: state) let cooldown = state.lastCooldownInterval ?? self.defaultCooldownInterval - if let lastAttemptAt = state.lastAttemptAt, now.timeIntervalSince(lastAttemptAt) < cooldown { + if !bypassCooldown, + let lastAttemptAt = state.lastAttemptAt, + now.timeIntervalSince(lastAttemptAt) < cooldown + { return false } @@ -431,6 +508,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { [String: String]) async throws -> Void)? @TaskLocal static var keychainFingerprintOverrideForTesting: (@Sendable () -> ClaudeOAuthCredentialsStore .ClaudeKeychainFingerprint?)? + @TaskLocal static var userInitiatedBackgroundJoinObserverForTesting: (@Sendable () -> Void)? static func withCLIAvailableOverrideForTesting( _ override: Bool?, @@ -459,6 +537,15 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { } } + static func withUserInitiatedBackgroundJoinObserverForTesting( + _ observer: (@Sendable () -> Void)?, + operation: () async throws -> T) async rethrows -> T + { + try await self.$userInitiatedBackgroundJoinObserverForTesting.withValue(observer) { + try await operation() + } + } + static func withIsolatedStateForTesting(operation: () async throws -> T) async rethrows -> T { let state = AttemptStateStorage(persistsCooldown: false) return try await self.$stateStorageForTesting.withValue(state) { @@ -473,6 +560,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { state.lastAttemptAt = nil state.lastCooldownInterval = nil state.inFlightAttemptID = nil + state.inFlightInteraction = nil state.inFlightTask = nil state.nextAttemptID = 0 state.lock.unlock() diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 211249fe0a..90f9acf23a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -198,9 +198,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private static func currentClaudeOAuthDelegatedRefreshPolicy() -> ClaudeOAuthKeychainPromptPolicy { + // Delegated refresh must honor the stored prompt mode for every keychain read strategy. + // securityCLIExperimental is not "prompt policy N/A" for CLI touch repair. ClaudeOAuthKeychainPromptPolicy( - mode: ClaudeOAuthKeychainPromptPreference.current(), - isApplicable: ClaudeOAuthKeychainPromptPreference.isApplicable(), + mode: ClaudeOAuthKeychainPromptPreference.storedMode(), + isApplicable: true, interaction: ProviderInteractionContext.current) } @@ -208,7 +210,6 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { policy: ClaudeOAuthKeychainPromptPolicy, allowBackgroundDelegatedRefresh: Bool) throws { - guard policy.isApplicable else { return } if policy.mode == .never { throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.") } @@ -1396,6 +1397,8 @@ extension ClaudeUsageFetcher { "decodeFailed" case .missingOAuth: "missingOAuth" + case .mcpOAuthOnlyKeychain: + "mcpOAuthOnlyKeychain" case .missingAccessToken: "missingAccessToken" case .notFound: diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift index d17e2d699e..66b1996322 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -499,6 +499,90 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } } } + + @Test + func `expired claude CLI owner blocks background mcp O auth but lets user action delegate`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let mcpOAuthOnly = Data(""" + { + "mcpOAuth": { + "plugin:slack:slack": { "accessToken": "" } + } + } + """.utf8) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .data(mcpOAuthOnly)) + { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired-claude-cli-owner", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .claudeCLI)) + + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected mcpOAuth-only keychain error") + } catch let error as ClaudeOAuthCredentialsError { + guard case .mcpOAuthOnlyKeychain = error else { + Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + Issue.record("Expected delegated refresh on explicit user action") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + }) + } + } + } } private final class ClaudeOAuthTokenRefreshStubURLProtocol: URLProtocol { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift new file mode 100644 index 0000000000..31bc939006 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests { + @Test + func `isolated security CLI keychain requires global keychain disable`() { + let keychainPath = "/tmp/codexbar-fixtures/verify.keychain-db" + let isolatedEnvironment = [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath, + ] + let expectedArguments = [ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + keychainPath, + ] + + #expect(KeychainAccessGate.isDisabledByEnvironment(isolatedEnvironment)) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: isolatedEnvironment) == expectedArguments) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [ + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath, + ]) == nil) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) == nil) + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "relative.keychain-db", + ]) == nil) + } + + @Test + func `isolated security CLI keychain remains readable while other keychain access is disabled`() { + let mcpOnlyPayload = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + let environment = [ + KeychainAccessGate.disableAccessEnvironmentKey: "1", + ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db", + ] + + let isMcpOnly = ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: environment) + } + #expect(isMcpOnly) + + let blockedWithoutIsolatedKeychain = ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) + } + #expect(blockedWithoutIsolatedKeychain == false) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift new file mode 100644 index 0000000000..36ea589e94 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests { + @Test + func `standard reader blocks expired CLI owner in background but preserves user refresh`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let credentialsURL = tempDir.appendingPathComponent("credentials.json") + + await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: mcpOAuthOnly, + fingerprint: nil) + { + let isMcpOnly = ProviderInteractionContext.$current.withValue(.background) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityFramework, + keychainAccessDisabled: false, + environment: [:]) + } + #expect(isMcpOnly) + + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.expiredCredentialsData, + storedAt: Date(), + owner: .claudeCLI)) + + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected MCP-only keychain failure") + } catch let error as ClaudeOAuthCredentialsError { + guard case .mcpOAuthOnlyKeychain = error else { + Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + + do { + _ = try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + } + Issue.record("Expected explicit user Refresh to delegate") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + private var expiredCredentialsData: Data { + let json = #""" + { + "claudeAiOauth": { + "accessToken": "expired", + "refreshToken": "refresh", + "expiresAt": 1000, + "scopes": ["user:profile"] + } + } + """# + return Data(json.utf8) + } +} +#endif diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index d2d139a9a9..fa3cb765e3 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -334,6 +334,136 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(counter.count == 1) } + @Test + func `user action retries after joining failed background attempt`() async throws { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + + actor Gate { + private var releaseContinuation: CheckedContinuation? + private var startedContinuation: CheckedContinuation? + private var joinedContinuation: CheckedContinuation? + private var hasStarted = false + private var isReleased = false + private var hasJoined = false + + func markStarted() { + self.hasStarted = true + self.startedContinuation?.resume() + self.startedContinuation = nil + } + + func waitStarted() async { + if self.hasStarted { return } + await withCheckedContinuation { self.startedContinuation = $0 } + } + + func release() { + self.isReleased = true + self.releaseContinuation?.resume() + self.releaseContinuation = nil + } + + func waitRelease() async { + if self.isReleased { return } + await withCheckedContinuation { self.releaseContinuation = $0 } + } + + func markJoined() { + self.hasJoined = true + self.joinedContinuation?.resume() + self.joinedContinuation = nil + } + + func waitJoined() async { + if self.hasJoined { return } + await withCheckedContinuation { self.joinedContinuation = $0 } + } + } + + final class StateBox: @unchecked Sendable { + private let lock = NSLock() + private var touchCount = 0 + private var fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "before") + + func beginTouch() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.touchCount += 1 + return self.touchCount + } + + func markChanged() { + self.lock.lock() + self.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 2, + persistentRefHash: "after") + self.lock.unlock() + } + + func snapshot() -> (Int, ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint) { + self.lock.lock() + defer { self.lock.unlock() } + return (self.touchCount, self.fingerprint) + } + } + + let gate = Gate() + let state = StateBox() + let outcomes = try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { + try await self.withCoordinatorOverrides( + isolateState: false, + cliAvailable: true, + touchAuthPath: { _, _ in + if state.beginTouch() == 1 { + await gate.markStarted() + await gate.waitRelease() + throw StubError.failed + } + state.markChanged() + }, + keychainFingerprint: { state.snapshot().1 }, + operation: { + await ClaudeOAuthDelegatedRefreshCoordinator + .withUserInitiatedBackgroundJoinObserverForTesting { + Task { await gate.markJoined() } + } operation: { + let background = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 51000), + timeout: 2) + } + } + await gate.waitStarted() + let userInitiated = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 51001), + timeout: 2) + } + } + await gate.waitJoined() + await gate.release() + return await (background.value, userInitiated.value) + } + }) + } + } + + guard case .attemptedFailed = outcomes.0 else { + Issue.record("Expected the background attempt to fail") + return + } + #expect(outcomes.1 == .attemptedSucceeded) + #expect(state.snapshot().0 == 2) + } + @Test func `experimental strategy does not use security framework fingerprint observation`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -570,4 +700,74 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(securityReadCounter.count < 1) } } + + @Test + func `experimental strategy blocks background mcp O auth but lets user action retry`() async { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + final class StateBox: @unchecked Sendable { + private let lock = NSLock() + private var touchCount = 0 + + func touch() { + self.lock.lock() + self.touchCount += 1 + self.lock.unlock() + } + + func count() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.touchCount + } + } + + let state = StateBox() + let mcpOAuthOnly = Data(""" + { + "mcpOAuth": { + "plugin:slack:slack": { "accessToken": "" } + } + } + """.utf8) + let refreshedCredentials = self.makeCredentialsData( + accessToken: "refreshed-after-user-action", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let outcomes = await self.withCoordinatorOverrides( + cliAvailable: true, + touchAuthPath: { _, _ in state.touch() }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in + state.count() > 0 ? refreshedCredentials : mcpOAuthOnly + }) { + let background = await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 63000), + timeout: 0.1) + } + let backgroundTouchCount = state.count() + let userInitiated = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 63001), + timeout: 0.1) + } + return (background, backgroundTouchCount, userInitiated) + } + }) + + guard case let .attemptedFailed(message) = outcomes.0 else { + Issue.record("Expected background .attemptedFailed outcome") + return + } + #expect(message.contains("MCP OAuth")) + #expect(outcomes.1 == 0) + #expect(outcomes.2 == .attemptedSucceeded) + #expect(state.count() == 1) + } + } + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index bb21493bf1..6dd3e9764e 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -52,6 +52,35 @@ struct ClaudeOAuthTests { } } + @Test + func `mcp O auth only keychain payload throws`() { + let json = """ + { + "mcpOAuth": { + "plugin:slack:slack": { + "accessToken": "" + } + } + } + """ + #expect(throws: ClaudeOAuthCredentialsError.self) { + _ = try ClaudeOAuthCredentials.parse(data: Data(json.utf8)) + } + } + + @Test + func `detects mcp O auth only keychain payload shape`() { + let json = """ + { + "mcpOAuth": { + "craft": { "accessToken": "" } + } + } + """ + let data = Data(json.utf8) + #expect(ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: data)) + } + @Test func `treats missing expiry as expired`() { let creds = ClaudeOAuthCredentials( diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 49951589f4..0c6033e781 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -1407,7 +1407,7 @@ extension ClaudeUsageTests { } @Test - func `oauth delegated retry experimental background ignores only on user action suppression`() async throws { + func `oauth delegated retry experimental background respects only on user action suppression`() async throws { let loadCounter = AsyncCounter() let delegatedCounter = AsyncCounter() let usageResponse = try Self.makeOAuthUsageResponse() @@ -1431,43 +1431,36 @@ extension ClaudeUsageTests { [String: String], Bool, Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in - let call = await loadCounter.increment() - if call == 1 { - throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI - } - return ClaudeOAuthCredentials( - accessToken: "fresh-token", - refreshToken: "refresh-token", - expiresAt: Date(timeIntervalSinceNow: 3600), - scopes: ["user:profile"], - rateLimitTier: nil) + _ = await loadCounter.increment() + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI } - let snapshot = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - try await ProviderInteractionContext.$current.withValue(.background) { - try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { - try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { - try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( - delegatedOverride) - { - try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( - loadCredsOverride) + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride) { - try await fetcher.loadLatestUsage(model: "sonnet") + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride) + { + try await fetcher.loadLatestUsage(model: "sonnet") + } } } } } } - } - }) + }) + } - #expect(await loadCounter.current() == 2) - #expect(await delegatedCounter.current() == 1) - #expect(snapshot.primary.usedPercent == 7) + #expect(await loadCounter.current() == 1) + #expect(await delegatedCounter.current() == 0) } @Test diff --git a/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift index 114824249a..1b3fad103d 100644 --- a/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift +++ b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift @@ -63,13 +63,8 @@ struct ClaudeOAuthDelegatedRefreshLinuxTests { interaction: .background, promptMode: .onlyOnUserAction) - #if os(Linux) - #expect(result.attempts == 1) - #expect(result.message.contains("still unavailable after delegated Claude CLI refresh")) - #else #expect(result.attempts == 0) #expect(result.message.contains("background repair is suppressed")) - #endif } private func runDelegatedRefresh( diff --git a/docs/claude.md b/docs/claude.md index bd088422f1..58d8f477d9 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -65,6 +65,7 @@ Admin API key setup: - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. +- On Claude Code 2.1.x, `Claude Code-credentials` may contain only MCP server OAuth state (`mcpOAuth`) with no `claudeAiOauth`. CodexBar treats that as an OAuth configuration error, does not run background delegated `claude /status` refresh, and surfaces re-auth guidance. Use Web or CLI usage source, or restore a valid Claude OAuth keychain entry. See #1844. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). - Endpoint: - `GET https://api.anthropic.com/api/oauth/usage` diff --git a/docs/pr-1848-body.md b/docs/pr-1848-body.md new file mode 100644 index 0000000000..6b0a205f6c --- /dev/null +++ b/docs/pr-1848-body.md @@ -0,0 +1,51 @@ +## Summary + +Fixes the background browser-launch regression in https://github.com/steipete/CodexBar/issues/1844: when Claude Code stores only MCP OAuth state in `Claude Code-credentials` (no `claudeAiOauth`), CodexBar no longer runs background delegated `claude /status` refresh—which can launch the default browser via `/usr/bin/open`. + +**Scope:** fail-closed safety guard for both keychain readers. Discovery of Claude Code 2.1.x's primary OAuth storage location remains tracked by https://github.com/steipete/CodexBar/issues/1823. + +## Problem + +On Claude Code 2.1.x, the `Claude Code-credentials` keychain item may contain only `mcpOAuth`. CodexBar then fails to parse Claude OAuth credentials, treats the session as expired, and may periodically attempt delegated CLI refresh. That path can open the user's default browser from the background. + +Contributing issues on `main`: + +1. Delegated refresh used `ClaudeOAuthKeychainPromptPreference.current()`, which becomes `.always` when the experimental security CLI reader is active—so `onlyOnUserAction` did not suppress background repair. +2. Delegated refresh could still invoke `claude /status` even when the keychain shape could not succeed. + +## Changes + +1. **Honor stored keychain prompt mode for delegated refresh** across all keychain read strategies (including `securityCLIExperimental`). Background refresh with `onlyOnUserAction` fails closed with existing user-action guidance instead of calling `claude /status`. +2. **Detect MCP-only keychain payloads through both keychain readers** via `ClaudeOAuthCredentialsError.mcpOAuthOnlyKeychain`, skip delegated CLI touch, and fail fast during expired Claude CLI credential load. +3. **Split security CLI read paths**: `readRawClaudeKeychainPayloadViaSecurityCLIIfEnabled` vs parsed credential load. +4. **Isolated verification helper**: the production `/usr/bin/security` reader can target a disposable keychain only while all general keychain access is disabled. `Scripts/verify_1844_live.sh` combines that keychain with disposable `HOME`, `CFFIXED_USER_HOME`, credentials, config, and a synthetic `claude` fixture that distinguishes benign CLI discovery from `/status` touch. + +## Tests + +- Updated: background delegated-refresh suppression with experimental reader +- Added: MCP-only parse/shape detection +- Added: coordinator test—background MCP-only guard plus explicit Refresh recovery +- Added: store test—expired CLI owner fails closed in background and delegates on explicit Refresh +- Added: fail-closed tests for the isolated-keychain argument seam +- Added: standard Security.framework reader regression—background fails closed while explicit Refresh delegates + +## Verification + +- [x] Focused macOS integration tests (2026-07-03) — details in `docs/verify-1844-proof.md` +- [x] Release-built `CodexBar.app` and packaged `CodexBarCLI` isolated live proof +- [x] Real Claude-tab Refresh click against the isolated built app +- [x] Final `make check`, 45-shard `make test`, and autoreview on the local port + +### Commands + +```bash +make check +swift test --filter ClaudeOAuthTests +swift test --filter ClaudeUsageTests +swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests +swift test --filter 'expired claude CLI owner blocks background' +swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests +./Scripts/verify_1844_live.sh +``` + +Fixes https://github.com/steipete/CodexBar/issues/1844. Primary OAuth storage discovery remains tracked by https://github.com/steipete/CodexBar/issues/1823. diff --git a/docs/pr-1848-verification-comment.md b/docs/pr-1848-verification-comment.md new file mode 100644 index 0000000000..a0bc8cdc98 --- /dev/null +++ b/docs/pr-1848-verification-comment.md @@ -0,0 +1,48 @@ +## Maintainer verification (2026-07-03) + +Local current-main port of https://github.com/steipete/CodexBar/pull/1848. This fixes the background browser-launch regression in https://github.com/steipete/CodexBar/issues/1844; primary OAuth storage discovery remains tracked by https://github.com/steipete/CodexBar/issues/1823. + +### Focused regression proof + +```bash +swift test --filter ClaudeOAuthTests +swift test --filter ClaudeUsageTests +swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests +swift test --filter 'expired claude CLI owner blocks background' +swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests +swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests +swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests +``` + +Result: **105 tests passed** (33 + 39 + 12 + 1 + 17 + 2 + 1). + +| Behavior | Result | +|----------|--------| +| Background `onlyOnUserAction` suppresses delegated refresh with `securityCLIExperimental` | Pass | +| MCP-only background guard prevents `claude /status` touch | Pass | +| Explicit user Refresh bypasses the MCP-only guard and cooldown | Pass | +| Explicit user Refresh retries after an in-flight background failure | Pass | +| Expired Claude CLI-owned credentials fail closed with `mcpOAuthOnlyKeychain` | Pass | +| Isolated keychain path is accepted only with general keychain access disabled | Pass | +| Standard Security.framework reader fails closed in background while explicit Refresh delegates | Pass | + +### Isolated built-bundle proof + +```bash +./Scripts/package_app.sh +./Scripts/verify_1844_live.sh +``` + +The verifier used only synthetic data under a unique temporary directory: + +- disposable `HOME` and `CFFIXED_USER_HOME` +- disposable keychain passed directly to `/usr/bin/security` +- general Security.framework/cache keychain access disabled +- isolated `.claude/.credentials.json` and CodexBar config +- synthetic `claude` executable that records benign discovery separately from `/status` touch + +The packaged `CodexBarCLI` exited 3 with the MCP-only guidance, no `/status` or browser/open canary appeared, and the user keychain search list was unchanged. The packaged `CodexBar.app` then exercised the synthetic CLI with `--version`, stayed running for five seconds after discovery, and still sent no `/status` or browser/open touch. + +For the explicit recovery path, I launched the same isolated built app, selected the real Claude tab, and clicked Refresh. Before the click the invocation log contained only `--version`; after the click it received `/status`, while the browser/open canary remained untouched. This proves user Refresh remains interaction-aware without weakening the background guard. + +Final local gates passed: `make check`, all 45 `make test` shards, exact-SHA autoreview, and source-blind behavior validation. diff --git a/docs/verify-1844-proof.md b/docs/verify-1844-proof.md new file mode 100644 index 0000000000..23a04cd68d --- /dev/null +++ b/docs/verify-1844-proof.md @@ -0,0 +1,46 @@ +# Verification: Claude MCP-only keychain guard + +Verification artifact for https://github.com/steipete/CodexBar/pull/1848, related to https://github.com/steipete/CodexBar/issues/1844. + +## Scope + +This verifies the safety behavior: CodexBar fails closed through both keychain readers when `Claude Code-credentials` contains only `mcpOAuth`, and background paths do not invoke delegated `claude /status` refresh. Explicit user Refresh remains able to attempt recovery. + +The change does not discover Claude Code 2.1.x's primary OAuth storage location. That broader provider-auth work remains tracked by https://github.com/steipete/CodexBar/issues/1823. + +## Focused regression proof + +```bash +swift test --filter ClaudeOAuthTests +swift test --filter ClaudeUsageTests +swift test --filter ClaudeOAuthDelegatedRefreshCoordinatorTests +swift test --filter 'expired claude CLI owner blocks background' +swift test --filter ClaudeOAuthCredentialsStoreSecurityCLITests +swift test --filter ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests +swift test --filter ClaudeOAuthCredentialsStoreMCPOnlyGuardTests +``` + +Result on macOS arm64: **105 tests passed** (33 + 39 + 12 + 1 + 17 + 2 + 1). + +The covered behaviors include MCP-only shape detection through both keychain readers, background fail-closed behavior, explicit user Refresh recovery, in-flight background/user interaction races, and fail-closed isolated-keychain argument construction. + +## Isolated built-bundle proof + +```bash +./Scripts/package_app.sh +./Scripts/verify_1844_live.sh +``` + +The verifier creates a unique temporary directory and places every synthetic credential fixture beneath it. `HOME` and `CFFIXED_USER_HOME` point there. A disposable keychain is passed as an explicit operand to `/usr/bin/security`; CodexBar's general keychain access is disabled so its Security.framework cache cannot read or write the user's login keychain. The script verifies that creating the disposable keychain does not change the user keychain search list. + +The packaged `CodexBarCLI` read an expired synthetic Claude credential file plus an MCP-only disposable keychain item. It exited 3 with the expected MCP-only guidance. A synthetic `claude` executable distinguishes benign discovery (`--version`) from an interactive `/status` touch. The packaged `CodexBar.app` exercised that exact CLI fixture, stayed running for five seconds after discovery, and never sent `/status`; the status and browser/open canaries stayed untouched. + +No real `~/.claude/.credentials.json`, Claude account, or CodexBar cache keychain item was read or mutated. The default keychain search list was read before and after fixture creation only to prove it remained unchanged. + +## Isolated user-Refresh replay + +The release-built app was also launched with the same disposable HOME, credentials, config, keychain, and classified Claude CLI fixture. Before interaction, the invocation log contained only `claude --version`; no `/status` or browser/open canary appeared. Using the real menu, the Claude tab was selected and Refresh was clicked. The fixture then received `/status`, proving the explicit user path still delegates, while the browser/open canary remained untouched. Cleanup deleted the disposable keychain and restored the user's previously running CodexBar app. + +## Final local gates + +`make check` passed, all 45 `make test` shards passed, exact-SHA autoreview reported no accepted/actionable findings, and the source-blind behavior contract passed.