From f8a0d690e3328b096b14c1faf58ea31711c21342 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Fri, 17 Jul 2026 23:42:54 -0700 Subject: [PATCH] Merge nucleic/sleek-river-urchin-2nzm into dev --- .../NucleicRemote/Models/HostConnection.swift | 18 ++- .../NucleicRemote/Models/RemoteStore.swift | 41 +++++++ .../Views/AgentAccountsView.swift | 103 +++++++++++++++++- 3 files changed, 154 insertions(+), 8 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 429ca29..a5455c5 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -33,6 +33,9 @@ final class HostConnection { /// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello /// and on every change; feeds Settings ▸ Agent Accounts. private(set) var agentAuthStatuses: [WireProviderAuthStatus] = [] + /// This host's credential-sealing X25519 public key, from its post-hello `credentialNeeded` + /// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed. + private(set) var credentialSealingKey: Data? /// The live transport, for the "Connected · …" chip. private(set) var activeTransport: SyncTransportHint = .lan @@ -837,14 +840,19 @@ final class HostConnection { directBox?.deliver(.go) case .directDecline(let reason): directBox?.deliver(.decline(reason)) - case .credentialNeeded, .credentialUpdate, + case .credentialNeeded(let need): + // The host's sealing key rides this push (kinds may be empty — a cockpit Mac never + // asks, a runner lists what it's missing). The phone is still not a credential + // *provider* — it holds no vault to answer `kinds` from — but the key is what the + // "use an API key" flow seals to (REMOTE_AGENT_LOGIN §8). + credentialSealingKey = need.sealingPublicKey.isEmpty ? nil : need.sealingPublicKey + callbacks.didUpdate() + case .credentialUpdate, // The owner's runner-pool credential (item 4) — inert until the phone grows a // pool-management surface; Macs are the managers today. .runnerPoolCredential: - // Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): a runner host - // asking for / mirroring sealed credentials. Inert here until the phone-side vault - // lands — and a host only sends these to clients that advertised the matching - // `WireClientCapabilities`, which this app doesn't yet. + // Remaining Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): inert + // here until the phone-side vault lands. break case .wireError(let error): if error.code == .channelMismatch { diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index eba94e4..76064cd 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -2116,6 +2116,47 @@ final class RemoteStore: ObservableObject { agentLogin = .done(success: result.succeeded, message: result.error) } + /// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8): + /// it advertises sealed-credential ingestion AND we hold its sealing key from the + /// post-hello `credentialNeeded` push. + func canSubmitAPIKey(toHost hostID: String) -> Bool { + guard let conn = connections[hostID] else { return false } + return conn.connectivity.isLive && conn.capabilities.canReceiveSealedCredentials + && conn.credentialSealingKey != nil + } + + /// Seal an API key directly to `hostID` and send it (REMOTE_AGENT_LOGIN §8 — the + /// ToS-defensive Console-key fallback). The key transits only as a sealed box inside the + /// E2EE channel and is NOT kept on the phone; the host lands it (Keychain stores on a Mac, + /// 0600 file + env on a runner) and the confirmation is implicit — the provider row flips + /// via the host's refreshed `agentAuthStatus` push. Returns false when the host can't take + /// it (caller shows the failure inline). + @discardableResult + func submitAPIKey(_ key: String, provider: AgentLoginProvider, toHost hostID: String) -> Bool { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + let kind: CredentialKind + switch provider { + case .claude: kind = .anthropicAPIKey + case .codex: kind = .openAIAPIKey + default: return false + } + guard !trimmed.isEmpty, + let conn = connections[hostID], conn.connectivity.isLive, + conn.capabilities.canReceiveSealedCredentials, + let sealingKey = conn.credentialSealingKey + else { return false } + let record = SealedCredentialRecord( + kind: kind, updatedAt: Date(), + box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data())) + guard let box = try? SealedCredentialBox.seal( + Data(trimmed.utf8), to: sealingKey, additionalData: record.additionalData) + else { return false } + conn.send(.credentialProvision(SealedCredentialEnvelope(records: [ + SealedCredentialRecord(kind: kind, updatedAt: Date(), box: box) + ]))) + return true + } + /// Record that the user looked at this session now (clears its unseen-completion wash). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift index 804cd96..07444e5 100644 --- a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -11,6 +11,16 @@ import NucleicProtocol /// (from its `agentAuthStatus` push) with a Sign in button where that host can broker the flow. struct AgentAccountsSection: View { @EnvironmentObject var store: RemoteStore + /// The row whose "Use API key…" sheet is open — (host, provider, display name). + @State private var apiKeyTarget: APIKeyTarget? + + struct APIKeyTarget: Identifiable { + let hostID: String + let hostName: String + let provider: AgentLoginProvider + let providerName: String + var id: String { hostID + "·" + provider.rawValue } + } var body: some View { let hosts = store.agentAccountHosts @@ -24,7 +34,7 @@ struct AgentAccountsSection: View { .textCase(.uppercase) } ForEach(host.statuses, id: \.provider.rawValue) { status in - providerRow(status, hostID: host.hostID) + providerRow(status, hostID: host.hostID, hostName: host.name) } } } header: { @@ -34,16 +44,22 @@ struct AgentAccountsSection: View { + "device in your mesh. This phone only shows the consent page and relays " + "the sign-in code over the encrypted channel.") } + .sheet(item: $apiKeyTarget) { target in + APIKeyEntrySheet(target: target) + } } } @ViewBuilder - private func providerRow(_ status: WireProviderAuthStatus, hostID: String) -> some View { + private func providerRow( + _ status: WireProviderAuthStatus, hostID: String, hostName: String + ) -> some View { + let name = status.name.isEmpty ? status.provider.rawValue.capitalized : status.name HStack(spacing: 10) { Image(systemName: status.authenticated ? "checkmark.seal.fill" : "person.crop.circle.badge.questionmark") .foregroundStyle(status.authenticated ? Color.green : Color.secondary) VStack(alignment: .leading, spacing: 2) { - Text(status.name.isEmpty ? status.provider.rawValue.capitalized : status.name) + Text(name) Text(detail(for: status)) .font(.footnote) .foregroundStyle(.secondary) @@ -56,13 +72,30 @@ struct AgentAccountsSection: View { .buttonStyle(.borderless) .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 + // when the host can take a sealed key from this phone. + if apiKeyProviders.contains(status.provider), store.canSubmitAPIKey(toHost: hostID) { + Button { + apiKeyTarget = APIKeyTarget( + hostID: hostID, hostName: hostName, + provider: status.provider, providerName: name) + } label: { + Image(systemName: "key") + } + .buttonStyle(.borderless) + .accessibilityLabel("Use an API key for \(name)") + } } } + private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] } + private func detail(for status: WireProviderAuthStatus) -> String { switch (status.installed, status.authenticated) { case (true, true): if let label = status.accountLabel, !label.isEmpty { return "Signed in · \(label)" } + if status.method == "apiKey" { return "Signed in · API key" } return "Signed in" case (true, false): return "Installed, not signed in" case (false, _): return "Not installed on this host" @@ -70,6 +103,70 @@ struct AgentAccountsSection: View { } } +/// SecureField entry for a provider API key, sealed straight to the chosen host +/// (REMOTE_AGENT_LOGIN §8). The phone never stores the key; confirmation is implicit — the +/// provider row flips to "Signed in · API key" when the host's refreshed status push lands. +private struct APIKeyEntrySheet: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + let target: AgentAccountsSection.APIKeyTarget + + @State private var key = "" + @State private var sent = false + @State private var failed = false + + private var keyName: String { + target.provider == .claude ? "Anthropic API key" : "OpenAI API key" + } + + var body: some View { + NavigationStack { + Form { + if sent { + Section { + Label("Key sent to \(target.hostName)", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + Text("The \(target.providerName) row will show “API key” once it lands.") + .font(.footnote).foregroundStyle(.secondary) + } + } else { + Section { + SecureField(keyName, text: $key) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + if failed { + Text("Couldn't send the key — the host may have disconnected. Try again.") + .font(.footnote).foregroundStyle(.red) + } + } footer: { + Text("Sealed to \(target.hostName) over the encrypted channel and stored " + + "there — never on this phone. Replaces any key already set.") + } + } + } + .navigationTitle("\(target.providerName) API key") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(sent ? "Done" : "Cancel") { dismiss() } + } + if !sent { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let ok = store.submitAPIKey( + key, provider: target.provider, toHost: target.hostID) + sent = ok + failed = !ok + } + .disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + .presentationDetents([.medium]) + } +} + /// Drives one sign-in attempt end to end, rendering whatever the flow state asks for: a spinner /// while the host builds the challenge, the vendor's consent page (with the loopback armed for /// auto-capture, or a paste bar for Claude's console fallback), and the final outcome.