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
|
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
|
||||||
/// and on every change; feeds Settings ▸ Agent Accounts.
|
/// and on every change; feeds Settings ▸ Agent Accounts.
|
||||||
private(set) var agentAuthStatuses: [WireProviderAuthStatus] = []
|
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.
|
/// The live transport, for the "Connected · …" chip.
|
||||||
private(set) var activeTransport: SyncTransportHint = .lan
|
private(set) var activeTransport: SyncTransportHint = .lan
|
||||||
|
|
||||||
@@ -837,14 +840,19 @@ final class HostConnection {
|
|||||||
directBox?.deliver(.go)
|
directBox?.deliver(.go)
|
||||||
case .directDecline(let reason):
|
case .directDecline(let reason):
|
||||||
directBox?.deliver(.decline(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
|
// The owner's runner-pool credential (item 4) — inert until the phone grows a
|
||||||
// pool-management surface; Macs are the managers today.
|
// pool-management surface; Macs are the managers today.
|
||||||
.runnerPoolCredential:
|
.runnerPoolCredential:
|
||||||
// Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): a runner host
|
// Remaining Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): inert
|
||||||
// asking for / mirroring sealed credentials. Inert here until the phone-side vault
|
// here until the phone-side vault lands.
|
||||||
// lands — and a host only sends these to clients that advertised the matching
|
|
||||||
// `WireClientCapabilities`, which this app doesn't yet.
|
|
||||||
break
|
break
|
||||||
case .wireError(let error):
|
case .wireError(let error):
|
||||||
if error.code == .channelMismatch {
|
if error.code == .channelMismatch {
|
||||||
|
|||||||
@@ -2116,6 +2116,47 @@ final class RemoteStore: ObservableObject {
|
|||||||
agentLogin = .done(success: result.succeeded, message: result.error)
|
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).
|
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||||
func markOpened(_ sessionID: SessionID) {
|
func markOpened(_ sessionID: SessionID) {
|
||||||
lastOpenedAt[sessionID] = Date()
|
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.
|
/// (from its `agentAuthStatus` push) with a Sign in button where that host can broker the flow.
|
||||||
struct AgentAccountsSection: View {
|
struct AgentAccountsSection: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@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 {
|
var body: some View {
|
||||||
let hosts = store.agentAccountHosts
|
let hosts = store.agentAccountHosts
|
||||||
@@ -24,7 +34,7 @@ struct AgentAccountsSection: View {
|
|||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
}
|
}
|
||||||
ForEach(host.statuses, id: \.provider.rawValue) { status in
|
ForEach(host.statuses, id: \.provider.rawValue) { status in
|
||||||
providerRow(status, hostID: host.hostID)
|
providerRow(status, hostID: host.hostID, hostName: host.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -34,16 +44,22 @@ struct AgentAccountsSection: View {
|
|||||||
+ "device in your mesh. This phone only shows the consent page and relays "
|
+ "device in your mesh. This phone only shows the consent page and relays "
|
||||||
+ "the sign-in code over the encrypted channel.")
|
+ "the sign-in code over the encrypted channel.")
|
||||||
}
|
}
|
||||||
|
.sheet(item: $apiKeyTarget) { target in
|
||||||
|
APIKeyEntrySheet(target: target)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: status.authenticated ? "checkmark.seal.fill" : "person.crop.circle.badge.questionmark")
|
Image(systemName: status.authenticated ? "checkmark.seal.fill" : "person.crop.circle.badge.questionmark")
|
||||||
.foregroundStyle(status.authenticated ? Color.green : Color.secondary)
|
.foregroundStyle(status.authenticated ? Color.green : Color.secondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(status.name.isEmpty ? status.provider.rawValue.capitalized : status.name)
|
Text(name)
|
||||||
Text(detail(for: status))
|
Text(detail(for: status))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -56,13 +72,30 @@ struct AgentAccountsSection: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.font(.callout)
|
.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 {
|
private func detail(for status: WireProviderAuthStatus) -> String {
|
||||||
switch (status.installed, status.authenticated) {
|
switch (status.installed, status.authenticated) {
|
||||||
case (true, true):
|
case (true, true):
|
||||||
if let label = status.accountLabel, !label.isEmpty { return "Signed in · \(label)" }
|
if let label = status.accountLabel, !label.isEmpty { return "Signed in · \(label)" }
|
||||||
|
if status.method == "apiKey" { return "Signed in · API key" }
|
||||||
return "Signed in"
|
return "Signed in"
|
||||||
case (true, false): return "Installed, not signed in"
|
case (true, false): return "Installed, not signed in"
|
||||||
case (false, _): return "Not installed on this host"
|
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
|
/// 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
|
/// 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.
|
/// auto-capture, or a paste bar for Claude's console fallback), and the final outcome.
|
||||||
|
|||||||
Reference in New Issue
Block a user