diff --git a/native/Apps/iOS/MobileAppModel.swift b/native/Apps/iOS/MobileAppModel.swift index 8d6d55f..af66d32 100644 --- a/native/Apps/iOS/MobileAppModel.swift +++ b/native/Apps/iOS/MobileAppModel.swift @@ -737,7 +737,22 @@ final class MobileAppModel: ObservableObject { pendingApproval = nil voice.approvalResolved() } catch { - errorMessage = String(describing: error) + if let remote = error as? RemoteAPIError, + ["approval_expired", "approval_not_pending", "approval_delivery_failed"].contains(remote.code) { + pendingApproval = nil + voice.approvalResolved() + await refreshApprovals(approval.conversationId) + if remote.code == "approval_expired" { + errorMessage = "That approval expired before the decision reached your Mac. Run the action again if it is still needed." + } else if remote.code == "approval_delivery_failed" { + errorMessage = "Your decision reached the Mac, but \(approval.provider.displayName) could not accept it. The turn was stopped safely." + } + // `approval_not_pending` means the other mirrored client + // already resolved it. Clearing the sheet is the success + // path; no error alert is useful. + } else { + errorMessage = String(describing: error) + } } busy = false } @@ -976,17 +991,24 @@ final class MobileAppModel: ObservableObject { while !Task.isCancelled { try? await Task.sleep(for: .seconds(2)) guard let self else { continue } - do { - if let id = self.activeConversation?.id { + if let id = self.activeConversation?.id { + do { try await self.refreshNewMessages(id) - await self.refreshApprovals(id) + self.lastSyncError = nil + } catch { + self.lastSyncError = String(describing: error) } + // Approval state is independently laptop-owned. A message + // sync failure must never leave an expired or phone-decided + // approval stuck on screen. + await self.refreshApprovals(id) + } + do { pollCount += 1 if pollCount.isMultiple(of: 5) { try await self.refreshProviderSnapshots() try await self.refreshLoadedThreadWindow() } - self.lastSyncError = nil } catch { self.lastSyncError = String(describing: error) } diff --git a/native/Apps/macOS/DesktopClientModel.swift b/native/Apps/macOS/DesktopClientModel.swift index 0636aa4..80d6048 100644 --- a/native/Apps/macOS/DesktopClientModel.swift +++ b/native/Apps/macOS/DesktopClientModel.swift @@ -389,11 +389,23 @@ final class DesktopClientModel: ObservableObject { while !Task.isCancelled { try? await Task.sleep(for: .seconds(2)) guard let self else { return } - do { - if let id = self.activeConversation?.id { + if let id = self.activeConversation?.id { + do { try await self.refreshNewMessages(id) + self.lastSyncError = nil + } catch { + self.lastSyncError = self.describe(error) + } + do { try await self.refreshApprovals(id) + } catch { + // Keep the last known sheet only for a transient + // approval-fetch failure; the next independent poll + // retries even when message sync remains unavailable. + self.lastSyncError = self.describe(error) } + } + do { count += 1 if count.isMultiple(of: 5) { try await self.refreshProviderSnapshots() @@ -1222,6 +1234,10 @@ final class DesktopClientModel: ObservableObject { let signer = try await keyManager.signer(for: .approval) let decision = try await ApprovalDecisionSigner(deviceID: deviceID, signer: signer) .sign(approval: approval, choice: choice) + try await synchronizeLocalApprovalIdentity( + approvalPublicKey: signer.encodedPublicKey, + deviceID: deviceID + ) let _: Approval = try await api.post( "/api/v1/approvals/\(approval.id)/decision", input: decision, @@ -1229,7 +1245,18 @@ final class DesktopClientModel: ObservableObject { ) pendingApproval = nil } catch { - errorMessage = describe(error) + if let remote = error as? RemoteAPIError, + ["approval_expired", "approval_not_pending", "approval_delivery_failed"].contains(remote.code) { + pendingApproval = nil + try? await refreshApprovals(approval.conversationId) + if remote.code == "approval_expired" { + errorMessage = "That approval expired before the decision reached the provider. Run the action again if it is still needed." + } else if remote.code == "approval_delivery_failed" { + errorMessage = "Your decision was recorded, but \(approval.provider.displayName) could not accept it. The turn was stopped safely." + } + } else { + errorMessage = describe(error) + } } busy = false } @@ -1359,6 +1386,30 @@ final class DesktopClientModel: ObservableObject { } } + /// A legacy desktop approval key may be biometric-only. `DeviceKeyManager` + /// rotates it to a Secure Enclave user-presence key on first use, after + /// which this updates only the already-enrolled loopback Mac identity. The + /// phone pairing and canonical context are deliberately untouched. + private func synchronizeLocalApprovalIdentity( + approvalPublicKey: String, + deviceID: String + ) async throws { + let devices = try await enrollment.listDevices() + guard let local = devices.first(where: { $0.id == deviceID }), + local.approvalPublicKey != approvalPublicKey else { return } + let signing = try await keyManager.signer(for: .request) + let enrolled = try await enrollment.repair( + signingPublicKey: signing.encodedPublicKey, + approvalPublicKey: approvalPublicKey, + displayName: Host.current().localizedName ?? "This Mac" + ) + guard enrolled.deviceId == deviceID else { + throw ExarchError.unavailable("The local approval identity changed unexpectedly") + } + try enrollment.remember(enrolled, signingPublicKey: signing.encodedPublicKey) + knownDevices = try await enrollment.listDevices() + } + private func describe(_ error: Error) -> String { if let exarch = error as? ExarchError { switch exarch { diff --git a/native/Sources/ExarchFoundation/DeviceKeys.swift b/native/Sources/ExarchFoundation/DeviceKeys.swift index 6d40a78..fee57e0 100644 --- a/native/Sources/ExarchFoundation/DeviceKeys.swift +++ b/native/Sources/ExarchFoundation/DeviceKeys.swift @@ -35,6 +35,19 @@ public actor DeviceKeyManager { try store.write(try representation(of: signer), account: account) return signer } +#endif +#if os(macOS) && !targetEnvironment(simulator) + // Marker 1 approval keys were created with + // `biometryCurrentSet`, which makes Touch ID the only way to use + // them. Rotate locally to a Secure Enclave `userPresence` key so + // macOS can offer the account password fallback. The desktop + // client repairs the daemon's public approval key only after the + // new key has successfully authenticated and signed a decision. + if purpose == .approval, stored.first == 1 { + let signer = try create(purpose: purpose) + try store.write(try representation(of: signer), account: account) + return signer + } #endif return try decode(stored, purpose: purpose) } @@ -59,7 +72,13 @@ public actor DeviceKeyManager { #endif if SecureEnclave.isAvailable { var flags: SecAccessControlCreateFlags = [.privateKeyUsage] - if purpose == .approval { flags.insert(.biometryCurrentSet) } + if purpose == .approval { +#if os(macOS) + flags.insert(.userPresence) +#else + flags.insert(.biometryCurrentSet) +#endif + } var error: Unmanaged? guard let access = SecAccessControlCreateWithFlags( nil, @@ -87,6 +106,11 @@ public actor DeviceKeyManager { dataRepresentation: Data(keyData), requiresPresence: purpose == .approval ) + case 3: + return SecureEnclaveSigner( + dataRepresentation: Data(keyData), + requiresPresence: purpose == .approval + ) case 2: return SoftwareP256Signer( key: try P256.Signing.PrivateKey(rawRepresentation: keyData), @@ -106,7 +130,13 @@ public actor DeviceKeyManager { } private func representation(of signer: any P256PayloadSigner) throws -> Data { - if let secure = signer as? SecureEnclaveSigner { return Data([1]) + secure.dataRepresentation } + if let secure = signer as? SecureEnclaveSigner { +#if os(macOS) + return Data([3]) + secure.dataRepresentation +#else + return Data([1]) + secure.dataRepresentation +#endif + } if let software = signer as? SoftwareP256Signer { return Data([2]) + software.key.rawRepresentation } throw ExarchError.invalidEncoding } @@ -139,6 +169,9 @@ public struct SecureEnclaveSigner: P256PayloadSigner, @unchecked Sendable { public func sign(_ payload: Data, reason: String?) async throws -> Data { let context: LAContext? = requiresPresence ? LAContext() : nil context?.localizedReason = reason ?? "Approve this action" +#if os(macOS) + context?.localizedFallbackTitle = "Use Mac Password" +#endif let key = try SecureEnclave.P256.Signing.PrivateKey( dataRepresentation: dataRepresentation, authenticationContext: context diff --git a/packages/core/src/store/canonical-store.test.ts b/packages/core/src/store/canonical-store.test.ts index c8d634c..59859c1 100644 --- a/packages/core/src/store/canonical-store.test.ts +++ b/packages/core/src/store/canonical-store.test.ts @@ -704,7 +704,7 @@ describe("CanonicalStore", () => { }); it("records one bounded decision for a pending unexpired approval", () => { - const now = new Date("2026-08-23T12:00:00Z"); + let now = new Date("2026-08-23T12:00:00Z"); const store = new CanonicalStore(":memory:", { now: () => now }); const project = store.createProject({ name: "Approval", @@ -741,8 +741,26 @@ describe("CanonicalStore", () => { decidedAt: now.toISOString(), signature: "signature" }) - ).toThrow("not pending"); + ).toThrow("already decided"); expect(store.markApprovalDeliveryFailed(approval.id).status).toBe("delivery_failed"); + + const expiring = store.createApproval({ + id: "approval_2", + conversationId: conversation.id, + turnId: "turn_2", + provider: "hermes", + request: { choices: ["once", "deny"], providerRequestId: "native_2" }, + expiresAt: "2026-08-23T12:01:00Z" + }); + now = new Date("2026-08-23T12:02:00Z"); + expect(() => store.recordApprovalDecision({ + approvalId: expiring.id, + choice: "once", + deviceId: "device_1", + decidedAt: now.toISOString(), + signature: "signature" + })).toThrow("expired before the decision arrived"); + expect(store.getApproval(expiring.id).status).toBe("expired"); store.close(); }); diff --git a/packages/core/src/store/canonical-store.ts b/packages/core/src/store/canonical-store.ts index 78fdbea..e38f10f 100644 --- a/packages/core/src/store/canonical-store.ts +++ b/packages/core/src/store/canonical-store.ts @@ -26,6 +26,20 @@ export interface CanonicalStoreOptions { now?: () => Date; } +export class ApprovalNotPendingError extends Error { + constructor(readonly status: ApprovalRecord["status"]) { + super(`Approval is already ${status}`); + this.name = "ApprovalNotPendingError"; + } +} + +export class ApprovalExpiredError extends Error { + constructor() { + super("Approval expired before the decision arrived"); + this.name = "ApprovalExpiredError"; + } +} + export interface ProjectRecord { id: string; name: string; @@ -748,10 +762,11 @@ export class CanonicalStore { signature: string; }): ApprovalRecord { const approval = this.getApproval(input.approvalId); - if (approval.status !== "pending") throw new Error("Approval is not pending"); + if (approval.status === "expired") throw new ApprovalExpiredError(); + if (approval.status !== "pending") throw new ApprovalNotPendingError(approval.status); if (Date.parse(approval.expiresAt) <= this.now().getTime()) { this.database.prepare("UPDATE approvals SET status = 'expired' WHERE id = ?").run(input.approvalId); - throw new Error("Approval expired"); + throw new ApprovalExpiredError(); } const choices = Array.isArray(approval.request.choices) ? approval.request.choices.filter((choice): choice is string => typeof choice === "string") diff --git a/services/daemon/src/api-server.ts b/services/daemon/src/api-server.ts index 7c05454..f26d162 100644 --- a/services/daemon/src/api-server.ts +++ b/services/daemon/src/api-server.ts @@ -10,6 +10,8 @@ import { type SignedRequestHeaders } from "../../../packages/protocol/src/index.js"; import { + ApprovalExpiredError, + ApprovalNotPendingError, AuthenticationError, DeviceAuthenticator, WorkspaceScopeError, @@ -454,6 +456,10 @@ export class LaptopApiServer { }); } else if (error instanceof ApprovalDeliveryError) { sendJson(response, 502, { error: "approval_delivery_failed", message: error.message }); + } else if (error instanceof ApprovalExpiredError) { + sendJson(response, 410, { error: "approval_expired", message: error.message }); + } else if (error instanceof ApprovalNotPendingError) { + sendJson(response, 409, { error: "approval_not_pending", message: error.message }); } else if (error instanceof WorkspaceUnavailableError) { sendJson(response, 423, { error: "workspace_unavailable", message: error.message }); } else if (error instanceof PayloadTooLargeError) { diff --git a/services/daemon/src/coordinator.test.ts b/services/daemon/src/coordinator.test.ts index a5e704a..e08847a 100644 --- a/services/daemon/src/coordinator.test.ts +++ b/services/daemon/src/coordinator.test.ts @@ -403,6 +403,7 @@ describe("ConversationCoordinator", () => { }) ).rejects.toBeInstanceOf(ApprovalDeliveryError); expect(store.getApproval(approval?.id as string).status).toBe("delivery_failed"); + expect(claude.interruptedTurns).toContain(approval?.turnId); store.close(); }); diff --git a/services/daemon/src/coordinator.ts b/services/daemon/src/coordinator.ts index f0479f7..8fc2528 100644 --- a/services/daemon/src/coordinator.ts +++ b/services/daemon/src/coordinator.ts @@ -582,6 +582,15 @@ export class ConversationCoordinator { }); } catch (error) { this.store.markApprovalDeliveryFailed(approval.id); + // A recorded decision that the provider could not accept must not leave + // its native turn waiting forever. The decision remains auditable as a + // delivery failure and the provider turn is stopped best-effort. + try { + await adapter.interruptTurn(approval.turnId); + } catch { + // The original delivery error is the actionable failure returned to + // the client; interruption remains best-effort and audit state is kept. + } throw new ApprovalDeliveryError( error instanceof Error ? error.message : "Provider rejected approval delivery" );