Merge nucleic/sleek-river-urchin-2nzm into dev
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user