Merge nucleic/sleek-river-urchin-2nzm into dev

This commit is contained in:
2026-07-17 23:42:54 -07:00
parent bb6a692c12
commit f8a0d690e3
3 changed files with 154 additions and 8 deletions
@@ -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 {
@@ -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()
@@ -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.