Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions native/Apps/iOS/MobileAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
57 changes: 54 additions & 3 deletions native/Apps/macOS/DesktopClientModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -1222,14 +1234,29 @@ 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,
as: Approval.self
)
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
}
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 35 additions & 2 deletions native/Sources/ExarchFoundation/DeviceKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
Expand Down Expand Up @@ -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),
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/store/canonical-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
});

Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/store/canonical-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions services/daemon/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type SignedRequestHeaders
} from "../../../packages/protocol/src/index.js";
import {
ApprovalExpiredError,
ApprovalNotPendingError,
AuthenticationError,
DeviceAuthenticator,
WorkspaceScopeError,
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions services/daemon/src/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
9 changes: 9 additions & 0 deletions services/daemon/src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand Down