Merge nucleic/fuzzy-velvet-quail-rnyt into dev

This commit is contained in:
2026-07-18 01:14:37 -07:00
parent 8f67ed6dd1
commit 87d9a09803
4 changed files with 559 additions and 16 deletions
@@ -146,6 +146,10 @@ final class HostConnection {
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in } var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID. /// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in } var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
/// The phone vault changed under this connection — kinds landed from a
/// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the
/// held-kinds mirror the Settings surface reads.
var credentialsChanged: () -> Void = {}
} }
private let callbacks: Callbacks private let callbacks: Callbacks
@@ -627,6 +631,13 @@ final class HostConnection {
if welcome.capabilities.canCast { if welcome.capabilities.canCast {
send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors()))) send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors())))
} }
// Credential mesh, phone as HOLDER: tell a host that ingests sealed credentials
// what this phone can provide — descriptors, deletion tombstones, and the sealing
// key rotations get mirrored back to (never secret bytes). Same post-welcome slot
// as the Mac's PeerClient gossip.
if welcome.capabilities.canReceiveSealedCredentials {
gossipCredentialManifest()
}
// Warm-resubscribe from what we already have, so a reconnect on a long transcript replays // Warm-resubscribe from what we already have, so a reconnect on a long transcript replays
// only the gap rather than snapping back to the host's 200-event tail. // only the gap rather than snapping back to the host's 200-event tail.
if let id = openSessionID { if let id = openSessionID {
@@ -842,17 +853,43 @@ final class HostConnection {
directBox?.deliver(.decline(reason)) directBox?.deliver(.decline(reason))
case .credentialNeeded(let need): case .credentialNeeded(let need):
// The host's sealing key rides this push (kinds may be empty — a cockpit Mac never // 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 // asks, a runner lists what it's missing). The key is what the one-shot API-key
// *provider* — it holds no vault to answer `kinds` from — but the key is what the // flow seals to (REMOTE_AGENT_LOGIN §8) — cache it either way. Non-empty kinds
// "use an API key" flow seals to (REMOTE_AGENT_LOGIN §8). // are a real ask: answer from the phone vault, sealing ONLY requested kinds
// (empty kinds must solicit nothing — that contract is load-bearing for the
// cockpit Mac's key-advertisement push).
credentialSealingKey = need.sealingPublicKey.isEmpty ? nil : need.sealingPublicKey credentialSealingKey = need.sealingPublicKey.isEmpty ? nil : need.sealingPublicKey
callbacks.didUpdate() callbacks.didUpdate()
case .credentialUpdate, if !need.kinds.isEmpty, capabilities.canReceiveSealedCredentials {
// The owner's runner-pool credential (item 4) — inert until the phone grows a Task { [weak self] in
// pool-management surface; Macs are the managers today. guard let envelope = await PhoneCredentialVault.shared.sealedEnvelope(for: need)
.runnerPoolCredential: else { return }
// Remaining Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): inert self?.send(.credentialProvision(envelope))
// here until the phone-side vault lands. }
}
case .credentialUpdate(let envelope):
// A host rotated a credential and mirrored it here, sealed to this phone's vault
// key — land it newest-wins (the shared comparators), then re-gossip the manifest
// so the host's descriptor view tracks the fresh stamp.
Task { [weak self] in
let landed = await PhoneCredentialVault.shared.land(envelope)
guard !landed.isEmpty, let self else { return }
self.gossipCredentialManifest()
self.callbacks.credentialsChanged()
}
case .credentialRevoked(let tombstones):
// A credential kind was deleted mesh-wide — clear the phone vault's copy and
// record the stones so this phone's own manifest stops offering it. No re-send:
// the host that pushed this owns the fan-out; a replay no-ops in the vault.
Task { [weak self] in
let applied = await PhoneCredentialVault.shared.applyTombstones(tombstones)
guard !applied.isEmpty, let self else { return }
self.gossipCredentialManifest()
self.callbacks.credentialsChanged()
}
case .runnerPoolCredential:
// The owner's runner-pool credential (item 4) — inert until the phone grows a
// pool-management surface; Macs are the managers today.
break break
case .wireError(let error): case .wireError(let error):
if error.code == .channelMismatch { if error.code == .channelMismatch {
@@ -973,6 +1010,19 @@ final class HostConnection {
Task { await client.send(msg) } Task { await client.send(msg) }
} }
/// Push this phone's credential manifest at the host (phone as credential HOLDER):
/// descriptors + tombstones + the vault's sealing key, never secret bytes. Only ever sent
/// to a host that advertised `canReceiveSealedCredentials` (callers gate; an older host
/// throws on the unknown tag). Vault reads run on the vault actor — off the main actor.
func gossipCredentialManifest() {
guard capabilities.canReceiveSealedCredentials else { return }
Task { [weak self] in
let manifest = await PhoneCredentialVault.shared.manifest(
deviceID: IdentityStore.deviceID())
self?.send(.credentialManifest(manifest))
}
}
/// Pull the open session's *full* transcript via mesh full-transcript sync and merge it into the /// Pull the open session's *full* transcript via mesh full-transcript sync and merge it into the
/// transcript on screen. The cold `subscribe` only returns the host's 200-event tail, so without /// transcript on screen. The cold `subscribe` only returns the host's 200-event tail, so without
/// this the phone shows nothing from before it connected. `afterSeq: 0` asks for the whole history /// this the phone shows nothing from before it connected. `afterSeq: 0` asks for the whole history
@@ -0,0 +1,373 @@
import CryptoKit
import Foundation
import NucleicProtocol
import Security
/// The phone-side credential vault (docs/REMOTE_AGENT_LOGIN.md follow-up: phone as credential
/// HOLDER). Holds the mirrorable rotating logins — Claude OAuth and Codex auth — so an
/// iPhone-primary mesh can credential a fresh runner with every Mac asleep: the phone gossips
/// a `CredentialManifest`, answers `credentialNeeded` for kinds it holds, and lands
/// `credentialUpdate` rotations with the exact same newest-wins comparators the Mac uses
/// (`ClaudeCredentialFormat` / `CodexCredentialFormat` in NucleicProtocol — shared, not
/// reimplemented). API keys stay deliberately out: the one-shot `submitAPIKey` push seals them
/// straight to a host and the phone never stores them.
///
/// At rest: one file, ChaChaPoly-sealed with a device-local 32-byte vault key. Where a Secure
/// Enclave exists, that vault key is wrapped by an SE-resident P-256 key
/// (`kSecAttrTokenIDSecureEnclave`) and only the wrapped blob touches the Keychain; without
/// one (simulator), the raw key lives in the Keychain (this-device-only, after-first-unlock).
///
/// HONESTY RULE (CLOUD_RUNTIME §5): the credential-sealing keypair is Curve25519, which CANNOT
/// live in the Secure Enclave (it holds P-256 only) — it is an ordinary Keychain item. What
/// the SE protects here is the at-rest wrap of the vault key. Never claim more.
///
/// Refresh leases: the phone may RECORD leases it learns but never ACQUIRES one — it
/// backgrounds unpredictably, and `CredentialRefreshLease.merged` deliberately prefers stable
/// holders (Macs/runners stay the refreshers).
///
/// An actor (not `@MainActor`): every Keychain and file touch runs off the main actor — the
/// Settings beach-ball lesson from AppStore applies to the phone too.
actor PhoneCredentialVault {
static let shared = PhoneCredentialVault()
/// The kinds the phone holds — the two rotating OAuth logins, matching
/// `RunnerCredentialVault.mirrorableKinds`. Static keys are never stored on the phone.
static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth]
// MARK: - Contents
private struct StoredRecord: Codable {
var payload: Data
/// The credential's own freshness stamp (Claude `expiresAt`, Codex `last_refresh`) —
/// the same clock every other mesh member merges on.
var updatedAt: Date
}
private struct VaultContents: Codable {
/// Keyed by `CredentialKind.rawValue`.
var records: [String: StoredRecord] = [:]
/// Recorded (never acquired) refresh leases — see the type doc. Empty today; kept in
/// the file shape so recording them later needs no migration.
var leases: [CredentialRefreshLease] = []
/// Mesh-wide deletions this phone knows (`deletedAt`-monotonic, one per kind).
var tombstones: [CredentialTombstone] = []
}
private var cachedContents: VaultContents?
private var cachedVaultKey: SymmetricKey?
// MARK: - Sealing keypair (Curve25519 — Keychain, NOT the Secure Enclave)
private static let sealingKeyAccount = "xyz.blakeslee.nucleic.remote.credential-sealing"
/// The public half other mesh members seal credentials to (rides in this phone's
/// manifest). Empty only if the Keychain refuses us entirely.
func sealingPublicKey() -> Data {
guard let key = sealingPrivateKey() else { return Data() }
return key.publicKey.rawRepresentation
}
private func sealingPrivateKey() -> Curve25519.KeyAgreement.PrivateKey? {
if let data = Self.keychainRead(account: Self.sealingKeyAccount),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data) {
return key
}
let fresh = Curve25519.KeyAgreement.PrivateKey()
guard Self.keychainWrite(fresh.rawRepresentation, account: Self.sealingKeyAccount)
else { return nil }
return fresh
}
// MARK: - Holder surface (manifest / provision / land / revoke)
/// What this phone gossips after `welcome` on a host that ingests sealed credentials:
/// descriptors for the kinds it holds (never the bytes), the tombstones it knows, and its
/// sealing key so rotations can be mirrored here. Leases ride through unchanged — recorded
/// only, never acquired (see the type doc).
func manifest(deviceID: String) -> CredentialManifest {
let contents = loadContents()
var records: [CredentialRecordDescriptor] = []
for (raw, record) in contents.records {
let kind = CredentialKind(rawValue: raw)
guard !suppressed(kind, updatedAt: record.updatedAt, in: contents) else { continue }
records.append(CredentialRecordDescriptor(
kind: kind, updatedAt: record.updatedAt, provenanceDeviceID: deviceID))
}
let key = sealingPublicKey()
return CredentialManifest(
records: records.sorted { $0.kind.rawValue < $1.kind.rawValue },
leases: contents.leases,
sealingPublicKey: key.isEmpty ? nil : key,
tombstones: contents.tombstones)
}
/// Seal every *requested* kind this phone holds to the asker's key — the answer to
/// `HostMsg.credentialNeeded`. Empty `kinds` solicits nothing (that contract is
/// load-bearing: a cockpit Mac pushes `kinds: []` purely to advertise its sealing key for
/// the one-shot API-key path, and no holder may volunteer anything for it).
func sealedEnvelope(for need: WireCredentialNeed) -> SealedCredentialEnvelope? {
guard !need.kinds.isEmpty else { return nil }
let contents = loadContents()
var records: [SealedCredentialRecord] = []
for kind in need.kinds {
guard let stored = contents.records[kind.rawValue],
!suppressed(kind, updatedAt: stored.updatedAt, in: contents) else { continue }
let stub = SealedCredentialRecord(
kind: kind, updatedAt: stored.updatedAt,
box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
guard let box = try? SealedCredentialBox.seal(
stored.payload, to: need.sealingPublicKey, additionalData: stub.additionalData)
else { continue }
records.append(SealedCredentialRecord(kind: kind, updatedAt: stored.updatedAt, box: box))
}
return records.isEmpty ? nil : SealedCredentialEnvelope(records: records)
}
/// Land a `credentialUpdate` (a host mirrored a rotation, sealed to this phone's key) —
/// newest-wins by the credential's own clock via the SAME comparators the Mac hubs use.
/// Returns the kinds actually written.
func land(_ envelope: SealedCredentialEnvelope) -> [CredentialKind] {
guard let key = sealingPrivateKey() else { return [] }
var contents = loadContents()
var landed: [CredentialKind] = []
for record in envelope.records where Self.mirrorableKinds.contains(record.kind) {
guard let plaintext = try? record.box.open(
with: key, additionalData: record.additionalData),
let json = String(data: plaintext, encoding: .utf8)
else { continue }
let current = contents.records[record.kind.rawValue]
.flatMap { String(data: $0.payload, encoding: .utf8) }
let stamp: Date
switch record.kind {
case .claudeOAuth:
guard ClaudeCredentialFormat.shouldReplace(candidate: json, current: current)
else { continue }
stamp = ClaudeCredentialFormat.expiresAt(json)
.map { Date(timeIntervalSince1970: $0 / 1000) } ?? record.updatedAt
case .codexAuth:
guard CodexCredentialFormat.shouldReplace(candidate: json, current: current)
else { continue }
stamp = CodexCredentialFormat.lastRefresh(json)
.map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt
default:
continue
}
// A revision at or before a recorded deletion stays dead — only a strictly newer
// login resurrects the kind. Judged on the credential's OWN clock (`stamp`), never
// the wire stamp: a mirror-back is stamped "now", which a Claude tombstone (bumped
// past the deleted token's future expiry) would wrongly suppress.
guard !suppressed(record.kind, updatedAt: stamp, in: contents) else { continue }
contents.records[record.kind.rawValue] = StoredRecord(payload: plaintext, updatedAt: stamp)
landed.append(record.kind)
}
if !landed.isEmpty { saveContents(contents) }
return landed
}
/// Land mesh-wide deletions (`HostMsg.credentialRevoked` or the post-hello table push):
/// merge each stone `deletedAt`-monotonic, drop the matching record, and absorb its
/// freshness stamp so no stale holder can resurrect it. Returns the stones that carried
/// new information (a replay returns empty — that's what terminates gossip loops).
@discardableResult
func applyTombstones(_ incoming: [CredentialTombstone]) -> [CredentialTombstone] {
var contents = loadContents()
var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) })
var applied: [CredentialTombstone] = []
for stone in incoming {
let winner = CredentialTombstone.merged(table[stone.kind], stone)
guard let winner, winner != table[stone.kind] else { continue }
var effective = winner
if let record = contents.records[stone.kind.rawValue] {
effective = winner.absorbing(freshness: record.updatedAt)
contents.records[stone.kind.rawValue] = nil
}
table[stone.kind] = effective
applied.append(effective)
}
guard !applied.isEmpty else { return [] }
contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue }
saveContents(contents)
return applied
}
/// The user deleted a credential from this phone (Agent Accounts): drop the local copy (if
/// any), mint the tombstone — absorbing the copy's freshness stamp — and return it for the
/// caller to send (`ClientMsg.credentialRevoke`) at every host that accepts the verb.
func deleteCredential(_ kind: CredentialKind, deviceID: String) -> CredentialTombstone {
var contents = loadContents()
var stone = CredentialTombstone(kind: kind, deletedAt: Date(), originDeviceID: deviceID)
if let record = contents.records[kind.rawValue] {
stone = stone.absorbing(freshness: record.updatedAt)
contents.records[kind.rawValue] = nil
}
var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) })
table[kind] = CredentialTombstone.merged(table[kind], stone) ?? stone
contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue }
saveContents(contents)
return table[kind] ?? stone
}
/// The kinds this phone currently holds (for the Settings surface).
func heldKinds() -> [CredentialKind] {
loadContents().records.keys.map(CredentialKind.init(rawValue:))
.sorted { $0.rawValue < $1.rawValue }
}
private func suppressed(
_ kind: CredentialKind, updatedAt: Date, in contents: VaultContents
) -> Bool {
contents.tombstones.first { $0.kind == kind }?
.suppresses(recordUpdatedAt: updatedAt) ?? false
}
// MARK: - At-rest encryption
private static var vaultFileURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Nucleic", isDirectory: true)
.appendingPathComponent("credential-vault.sealed")
}
private func loadContents() -> VaultContents {
if let cachedContents { return cachedContents }
guard let key = vaultKey(),
let sealed = try? Data(contentsOf: Self.vaultFileURL),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let contents = try? JSONDecoder().decode(VaultContents.self, from: plaintext)
else {
let empty = VaultContents()
cachedContents = empty
return empty
}
cachedContents = contents
return contents
}
private func saveContents(_ contents: VaultContents) {
cachedContents = contents
guard let key = vaultKey(),
let plaintext = try? JSONEncoder().encode(contents),
let sealed = try? ChaChaPoly.seal(plaintext, using: key)
else { return }
let url = Self.vaultFileURL
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try? sealed.combined.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
// MARK: - Vault key (SE-wrapped where available)
private static let wrappedKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key.wrapped"
private static let rawKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key"
private static let seKeyTag = Data("xyz.blakeslee.nucleic.remote.vault-wrap".utf8)
private func vaultKey() -> SymmetricKey? {
if let cachedVaultKey { return cachedVaultKey }
let key = loadOrCreateVaultKey()
cachedVaultKey = key
return key
}
private func loadOrCreateVaultKey() -> SymmetricKey? {
// Secure Enclave path: the vault key only ever exists in the clear in process memory;
// the Keychain holds the SE-wrapped blob, and the wrap key never leaves the enclave.
if SecureEnclave.isAvailable {
if let wrapped = Self.keychainRead(account: Self.wrappedKeyAccount),
let seKey = Self.loadSEKey(),
let raw = Self.seDecrypt(wrapped, with: seKey) {
return SymmetricKey(data: raw)
}
let fresh = SymmetricKey(size: .bits256)
let rawFresh = fresh.withUnsafeBytes { Data($0) }
if let seKey = Self.loadOrCreateSEKey(),
let wrapped = Self.seEncrypt(rawFresh, with: seKey),
Self.keychainWrite(wrapped, account: Self.wrappedKeyAccount) {
return fresh
}
// SE claimed available but refused (rare) — fall through to the plain-Keychain key.
}
if let raw = Self.keychainRead(account: Self.rawKeyAccount) {
return SymmetricKey(data: raw)
}
let fresh = SymmetricKey(size: .bits256)
let raw = fresh.withUnsafeBytes { Data($0) }
guard Self.keychainWrite(raw, account: Self.rawKeyAccount) else { return nil }
return fresh
}
// MARK: SE wrap primitives (SecKey — P-256 in the enclave, ECIES for the wrap)
private static func loadSEKey() -> SecKey? {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: seKeyTag,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
return (item as! SecKey)
}
private static func loadOrCreateSEKey() -> SecKey? {
if let existing = loadSEKey() { return existing }
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, nil)
else { return nil }
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: seKeyTag,
kSecAttrAccessControl as String: access,
],
]
return SecKeyCreateRandomKey(attributes as CFDictionary, nil)
}
private static let seAlgorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM
private static func seEncrypt(_ plaintext: Data, with privateKey: SecKey) -> Data? {
guard let publicKey = SecKeyCopyPublicKey(privateKey),
SecKeyIsAlgorithmSupported(publicKey, .encrypt, seAlgorithm)
else { return nil }
return SecKeyCreateEncryptedData(publicKey, seAlgorithm, plaintext as CFData, nil) as Data?
}
private static func seDecrypt(_ ciphertext: Data, with privateKey: SecKey) -> Data? {
guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, seAlgorithm) else { return nil }
return SecKeyCreateDecryptedData(privateKey, seAlgorithm, ciphertext as CFData, nil) as Data?
}
// MARK: Keychain (generic-password items, this-device-only)
private static func keychainRead(account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
return item as? Data
}
@discardableResult
private static func keychainWrite(_ data: Data, account: String) -> Bool {
let delete: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
]
SecItemDelete(delete as CFDictionary)
let add: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: data,
]
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
}
}
@@ -837,6 +837,8 @@ final class RemoteStore: ObservableObject {
func onAppear() { func onAppear() {
setupLiveActivityBridge() setupLiveActivityBridge()
if demoMode { seedDemo(); return } if demoMode { seedDemo(); return }
// Seed the phone-vault mirror (what this phone can credential a runner with).
refreshPhoneVaultKinds()
startNetworkingIfNeeded() startNetworkingIfNeeded()
if isPaired { if isPaired {
// Show the saved chat history immediately, before any host connects. // Show the saved chat history immediately, before any host connects.
@@ -1340,6 +1342,11 @@ final class RemoteStore: ObservableObject {
// Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID. // Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID.
self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome) self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome)
} }
cb.credentialsChanged = { [weak self] in
// The phone vault changed under this connection (a rotation landed / a mesh-wide
// deletion cleared a kind) — refresh the held-kinds mirror Settings shows.
self?.refreshPhoneVaultKinds()
}
cb.meshRosterChanged = { [weak self] in cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
// (connecting the newcomer) and drop any that left — without switching the active host. // (connecting the newcomer) and drop any that left — without switching the active host.
@@ -2125,6 +2132,46 @@ final class RemoteStore: ObservableObject {
&& conn.credentialSealingKey != nil && conn.credentialSealingKey != nil
} }
// MARK: - Phone credential vault (phone as credential holder)
/// The credential kinds this phone's encrypted vault currently holds — the Agent Accounts
/// footer's "this iPhone can credential a fresh runner" note. Refreshed whenever a
/// connection lands an update or a deletion.
@Published private(set) var phoneVaultKinds: [CredentialKind] = []
/// Re-read the vault's held kinds (vault I/O runs on its own actor, off the main actor).
func refreshPhoneVaultKinds() {
Task { [weak self] in
let kinds = await PhoneCredentialVault.shared.heldKinds()
self?.phoneVaultKinds = kinds
}
}
/// Whether `hostID` can land a mesh-wide credential deletion right now.
func canRevokeCredentials(onHost hostID: String) -> Bool {
guard let conn = connections[hostID] else { return false }
return conn.connectivity.isLive && conn.capabilities.canRevokeCredentials
}
/// Delete a credential kind from every mesh member: clear this phone's own vault copy,
/// mint the tombstone (absorbing the copy's freshness so a stale holder can't resurrect
/// it), and send it at every live host that accepts the verb — each host clears its
/// stores, records the stone, and fans it out to its other clients and peers. The
/// provider rows flip via the hosts' refreshed `agentAuthStatus` push (the implicit ack).
func revokeCredential(kind: CredentialKind) {
guard !demoMode else { return }
Task { [weak self] in
let stone = await PhoneCredentialVault.shared.deleteCredential(
kind, deviceID: IdentityStore.deviceID())
guard let self else { return }
for conn in self.connections.values
where conn.connectivity.isLive && conn.capabilities.canRevokeCredentials {
conn.send(.credentialRevoke([stone]))
}
self.refreshPhoneVaultKinds()
}
}
/// Seal an API key directly to `hostID` and send it (REMOTE_AGENT_LOGIN §8 — the /// 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 /// 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, /// E2EE channel and is NOT kept on the phone; the host lands it (Keychain stores on a Mac,
@@ -2481,15 +2528,17 @@ final class RemoteStore: ObservableObject {
// Never originated from here (connection-internal, or handled by dedicated loops). // Never originated from here (connection-internal, or handled by dedicated loops).
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
// The runner verbs (intelligence results, credential mesh — COVALENCE_RUNNER §5–6) will // The runner verbs ride their own loops: manifests/provisions go out per-connection
// ride their own executor/vault loops when the phone side lands; nothing routes them here. // from `HostConnection` (gossip on ready, answers to `credentialNeeded`), and
// `credentialRevoke` goes to every capable host from `revokeCredential(kind:)`.
// `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the // `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the
// phone grows that UI — a brand-new project has no owner to route by. // phone grows that UI — a brand-new project has no owner to route by.
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode, .respondMacPair, .requestPairingCode, .cancelPairingCode, .respondMacPair,
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject, .intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke,
.createProject,
// Remote agent sign-in goes straight to the user-chosen host from // Remote agent sign-in goes straight to the user-chosen host from
// `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is // `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is
// pinned to one host's PKCE state, so owner-routing can never apply. // pinned to one host's PKCE state, so owner-routing can never apply.
@@ -2695,7 +2744,7 @@ final class RemoteStore: ObservableObject {
// the demo path short-circuits in `requestPairingCode()` with a stand-in code. // the demo path short-circuits in `requestPairingCode()` with a stand-in code.
.requestPairingCode, .cancelPairingCode, .respondMacPair, .requestPairingCode, .cancelPairingCode, .respondMacPair,
// Covalence runner verbs (COVALENCE_RUNNER §5–6) — demo has no runner host. // Covalence runner verbs (COVALENCE_RUNNER §5–6) — demo has no runner host.
.intelligenceResult, .credentialManifest, .credentialProvision, .intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke,
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
.createProject, .createProject,
// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker
@@ -13,6 +13,9 @@ struct AgentAccountsSection: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
/// The row whose "Use API key…" sheet is open — (host, provider, display name). /// The row whose "Use API key…" sheet is open — (host, provider, display name).
@State private var apiKeyTarget: APIKeyTarget? @State private var apiKeyTarget: APIKeyTarget?
/// The mesh-wide deletion awaiting the user's confirm (it tombstones the credential on
/// EVERY device, so it always confirms first). Nil hides the dialog.
@State private var deleteTarget: DeleteTarget?
struct APIKeyTarget: Identifiable { struct APIKeyTarget: Identifiable {
let hostID: String let hostID: String
@@ -22,6 +25,12 @@ struct AgentAccountsSection: View {
var id: String { hostID + "·" + provider.rawValue } var id: String { hostID + "·" + provider.rawValue }
} }
struct DeleteTarget: Identifiable {
let kind: CredentialKind
let label: String
var id: String { kind.rawValue }
}
var body: some View { var body: some View {
let hosts = store.agentAccountHosts let hosts = store.agentAccountHosts
if !hosts.isEmpty { if !hosts.isEmpty {
@@ -40,16 +49,54 @@ struct AgentAccountsSection: View {
} header: { } header: {
Text("Agent accounts") Text("Agent accounts")
} footer: { } footer: {
Text("Sign-ins run on the host — your Mac or a cloud runner — and sync to every " Text(footerText)
+ "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 .sheet(item: $apiKeyTarget) { target in
APIKeyEntrySheet(target: target) APIKeyEntrySheet(target: target)
} }
.confirmationDialog(
"Delete the \(deleteTarget?.label ?? "credential") from every device in your mesh?",
isPresented: Binding(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }),
titleVisibility: .visible
) {
Button("Delete Everywhere", role: .destructive) {
guard let target = deleteTarget else { return }
deleteTarget = nil
store.revokeCredential(kind: target.kind)
}
Button("Cancel", role: .cancel) { deleteTarget = nil }
} message: {
Text("Your Macs, cloud runners, and this iPhone all drop it. A tombstone keeps "
+ "any offline device from bringing it back; signing in again re-enables "
+ "the provider everywhere.")
}
} }
} }
/// The section footer: the standard sign-in explainer, plus — once this phone actually
/// holds mirrored logins — the holder note (an encrypted copy lives here, so a fresh
/// runner can be credentialed with every Mac asleep).
private var footerText: String {
var text = "Sign-ins run on the host — your Mac or a cloud runner — and sync to every "
+ "device in your mesh. This phone only shows the consent page and relays "
+ "the sign-in code over the encrypted channel."
let held = store.phoneVaultKinds.compactMap { kind -> String? in
switch kind {
case .claudeOAuth: "Claude"
case .codexAuth: "Codex"
default: nil
}
}
if !held.isEmpty {
text += " This iPhone also keeps an encrypted copy of the "
+ held.joined(separator: " and ")
+ " sign-in, so it can credential a fresh runner on its own."
}
return text
}
@ViewBuilder @ViewBuilder
private func providerRow( private func providerRow(
_ status: WireProviderAuthStatus, hostID: String, hostName: String _ status: WireProviderAuthStatus, hostID: String, hostName: String
@@ -87,6 +134,30 @@ struct AgentAccountsSection: View {
.accessibilityLabel("Use an API key for \(name)") .accessibilityLabel("Use an API key for \(name)")
} }
} }
.contextMenu {
// Mesh-wide deletion (key deletion from any device): offered when a host that
// 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).
if status.authenticated, store.canRevokeCredentials(onHost: hostID) {
if status.method == "apiKey" {
Button(role: .destructive) {
deleteTarget = DeleteTarget(
kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey,
label: "\(name) API key")
} label: {
Label("Delete API Key on All Devices…", systemImage: "trash")
}
} else {
Button(role: .destructive) {
deleteTarget = DeleteTarget(
kind: status.provider == .codex ? .codexAuth : .claudeOAuth,
label: "\(name) sign-in")
} label: {
Label("Sign Out on All Devices…", systemImage: "trash")
}
}
}
}
} }
private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] } private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] }