From a67f91cff7f0cd89fcc548660c531c63a17244fa Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Sat, 8 Aug 2026 23:41:02 -0700 Subject: [PATCH] Merge nucleic/golden-opal-heron-lalz into dev --- .../Models/PhoneCredentialVault.swift | 9 +++- .../NucleicRemote/Models/RemoteStore.swift | 1 + .../Views/AgentAccountsView.swift | 49 ++++++++++++++----- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift b/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift index 3848756..fd46598 100644 --- a/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift +++ b/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift @@ -30,9 +30,9 @@ import Security actor PhoneCredentialVault { static let shared = PhoneCredentialVault() - /// The kinds the phone holds — the two rotating OAuth logins, matching + /// The kinds the phone holds — the rotating OAuth logins, matching /// `RunnerCredentialVault.mirrorableKinds`. Static keys are never stored on the phone. - static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth] + static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth, .grokAuth] // MARK: - Contents @@ -149,6 +149,11 @@ actor PhoneCredentialVault { else { continue } stamp = CodexCredentialFormat.lastRefresh(json) .map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt + case .grokAuth: + guard GrokCredentialFormat.shouldReplace(candidate: json, current: current) + else { continue } + stamp = GrokCredentialFormat.expiresAt(json) + .map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt default: continue } diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d60eabf..c120c7a 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -2592,6 +2592,7 @@ final class RemoteStore: ObservableObject { switch provider { case .claude: kind = .anthropicAPIKey case .codex: kind = .openAIAPIKey + case .grok: kind = .xaiAPIKey default: return false } guard !trimmed.isEmpty, diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift index 25c6d4f..b815582 100644 --- a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -86,6 +86,7 @@ struct AgentAccountsSection: View { switch kind { case .claudeOAuth: "Claude" case .codexAuth: "Codex" + case .grokAuth: "Grok" default: nil } } @@ -120,7 +121,7 @@ struct AgentAccountsSection: View { .font(.callout) } // The ToS-defensive fallback (REMOTE_AGENT_LOGIN §8): set a Console/API key - // instead of a subscription login. Only for the two key-backed providers, and only + // instead of a subscription login. Only for the key-backed providers, and only // when the host can take a sealed key from this phone. if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) { Button { @@ -139,19 +140,15 @@ struct AgentAccountsSection: View { // accepts the tombstone verb is reachable — the deletion then propagates from it // to every other member (and this phone clears its own vault copy regardless). if status.authenticated, store.canRevokeCredentials(onHost: hostID) { - if status.method == "apiKey" { + if status.method == "apiKey", let kind = Self.apiKeyKind(status.provider) { Button(role: .destructive) { - deleteTarget = DeleteTarget( - kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey, - label: "\(name) API key") + deleteTarget = DeleteTarget(kind: kind, label: "\(name) API key") } label: { Label("Delete API Key on All Devices…", systemImage: "trash") } - } else { + } else if status.method != "apiKey", let kind = Self.signInKind(status.provider) { Button(role: .destructive) { - deleteTarget = DeleteTarget( - kind: status.provider == .codex ? .codexAuth : .claudeOAuth, - label: "\(name) sign-in") + deleteTarget = DeleteTarget(kind: kind, label: "\(name) sign-in") } label: { Label("Sign Out on All Devices…", systemImage: "trash") } @@ -160,7 +157,29 @@ struct AgentAccountsSection: View { } } - private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] } + /// The mesh credential kind a provider's subscription sign-in lands as, and the one its API + /// key lands as. Nil for a provider this build doesn't know — a newer host can advertise one + /// (the wire type is raw-string-backed), and revoking the *wrong* kind would sign the user out + /// of a provider they didn't touch, so the menu item is simply withheld. + private static func signInKind(_ provider: AgentLoginProvider) -> CredentialKind? { + switch provider { + case .claude: .claudeOAuth + case .codex: .codexAuth + case .grok: .grokAuth + default: nil + } + } + + private static func apiKeyKind(_ provider: AgentLoginProvider) -> CredentialKind? { + switch provider { + case .claude: .anthropicAPIKey + case .codex: .openAIAPIKey + case .grok: .xaiAPIKey + default: nil + } + } + + private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex, .grok] } private func detail(for status: WireProviderAuthStatus) -> String { switch (status.installed, status.authenticated) { @@ -187,7 +206,11 @@ private struct APIKeyEntrySheet: View { @State private var failed = false private var keyName: String { - target.provider == .claude ? "Anthropic API key" : "OpenAI API key" + switch target.provider { + case .codex: "OpenAI API key" + case .grok: "xAI API key" + default: "Anthropic API key" + } } var body: some View { @@ -249,6 +272,7 @@ struct AgentLoginSheet: View { switch store.agentLoginProvider { case .some(.claude): "Claude" case .some(.codex): "Codex" + case .some(.grok): "Grok" case .some(let other): other.rawValue.capitalized case .none: "Agent" } @@ -352,8 +376,11 @@ enum AgentAuthErrors { || lowered.contains("authentication_error") || lowered.contains("authentication error") || lowered.contains("not logged in") + // Grok's signed-out turn ("Not signed in. To authenticate without a browser…"). + || lowered.contains("not signed in") || lowered.contains("oauth token has expired") || lowered.contains("please run /login") + || lowered.contains("run `grok login`") || lowered.contains("invalid api key") || lowered.contains("credential") && lowered.contains("expired")