Merge nucleic/golden-opal-heron-lalz into dev

This commit is contained in:
2026-08-08 23:41:02 -07:00
parent ff965ba977
commit a67f91cff7
3 changed files with 46 additions and 13 deletions
@@ -30,9 +30,9 @@ import Security
actor PhoneCredentialVault { actor PhoneCredentialVault {
static let shared = 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. /// `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 // MARK: - Contents
@@ -149,6 +149,11 @@ actor PhoneCredentialVault {
else { continue } else { continue }
stamp = CodexCredentialFormat.lastRefresh(json) stamp = CodexCredentialFormat.lastRefresh(json)
.map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt .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: default:
continue continue
} }
@@ -2592,6 +2592,7 @@ final class RemoteStore: ObservableObject {
switch provider { switch provider {
case .claude: kind = .anthropicAPIKey case .claude: kind = .anthropicAPIKey
case .codex: kind = .openAIAPIKey case .codex: kind = .openAIAPIKey
case .grok: kind = .xaiAPIKey
default: return false default: return false
} }
guard !trimmed.isEmpty, guard !trimmed.isEmpty,
@@ -86,6 +86,7 @@ struct AgentAccountsSection: View {
switch kind { switch kind {
case .claudeOAuth: "Claude" case .claudeOAuth: "Claude"
case .codexAuth: "Codex" case .codexAuth: "Codex"
case .grokAuth: "Grok"
default: nil default: nil
} }
} }
@@ -120,7 +121,7 @@ struct AgentAccountsSection: View {
.font(.callout) .font(.callout)
} }
// The ToS-defensive fallback (REMOTE_AGENT_LOGIN §8): set a Console/API key // 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. // when the host can take a sealed key from this phone.
if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) { if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) {
Button { Button {
@@ -139,19 +140,15 @@ struct AgentAccountsSection: View {
// accepts the tombstone verb is reachable — the deletion then propagates from it // 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). // to every other member (and this phone clears its own vault copy regardless).
if status.authenticated, store.canRevokeCredentials(onHost: hostID) { if status.authenticated, store.canRevokeCredentials(onHost: hostID) {
if status.method == "apiKey" { if status.method == "apiKey", let kind = Self.apiKeyKind(status.provider) {
Button(role: .destructive) { Button(role: .destructive) {
deleteTarget = DeleteTarget( deleteTarget = DeleteTarget(kind: kind, label: "\(name) API key")
kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey,
label: "\(name) API key")
} label: { } label: {
Label("Delete API Key on All Devices…", systemImage: "trash") Label("Delete API Key on All Devices…", systemImage: "trash")
} }
} else { } else if status.method != "apiKey", let kind = Self.signInKind(status.provider) {
Button(role: .destructive) { Button(role: .destructive) {
deleteTarget = DeleteTarget( deleteTarget = DeleteTarget(kind: kind, label: "\(name) sign-in")
kind: status.provider == .codex ? .codexAuth : .claudeOAuth,
label: "\(name) sign-in")
} label: { } label: {
Label("Sign Out on All Devices…", systemImage: "trash") 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 { private func detail(for status: WireProviderAuthStatus) -> String {
switch (status.installed, status.authenticated) { switch (status.installed, status.authenticated) {
@@ -187,7 +206,11 @@ private struct APIKeyEntrySheet: View {
@State private var failed = false @State private var failed = false
private var keyName: String { 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 { var body: some View {
@@ -249,6 +272,7 @@ struct AgentLoginSheet: View {
switch store.agentLoginProvider { switch store.agentLoginProvider {
case .some(.claude): "Claude" case .some(.claude): "Claude"
case .some(.codex): "Codex" case .some(.codex): "Codex"
case .some(.grok): "Grok"
case .some(let other): other.rawValue.capitalized case .some(let other): other.rawValue.capitalized
case .none: "Agent" case .none: "Agent"
} }
@@ -352,8 +376,11 @@ enum AgentAuthErrors {
|| lowered.contains("authentication_error") || lowered.contains("authentication_error")
|| lowered.contains("authentication error") || lowered.contains("authentication error")
|| lowered.contains("not logged in") || 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("oauth token has expired")
|| lowered.contains("please run /login") || lowered.contains("please run /login")
|| lowered.contains("run `grok login`")
|| lowered.contains("invalid api key") || lowered.contains("invalid api key")
|| lowered.contains("credential") || lowered.contains("credential")
&& lowered.contains("expired") && lowered.contains("expired")