mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
feat(ios): add exec approval notification flow (#60239)
* fix(auth): hand off qr bootstrap to bounded device tokens * feat(ios): add exec approval notification flow * fix(gateway): harden approval notification delivery * docs(changelog): add ios exec approval entry (#60239) (thanks @ngutman)
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)
|
||||
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
|
||||
- Tools/web_search: add a bundled MiniMax Search provider backed by the Coding Plan search API, with region reuse from `MINIMAX_API_HOST` and plugin-owned credential config. (#54648) Thanks @fengmk2.
|
||||
- iOS/exec approvals: add generic APNs approval notifications that open an in-app exec approval modal, fetch command details only after authenticated operator reconnect, and clear stale notification state when the approval resolves. (#60239) Thanks @ngutman.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
196
apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift
Normal file
196
apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if let prompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ZStack {
|
||||
Color.black.opacity(0.38)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ExecApprovalPromptCard(
|
||||
prompt: prompt,
|
||||
isResolving: self.appModel.pendingExecApprovalPromptResolving,
|
||||
errorText: self.appModel.pendingExecApprovalPromptErrorText,
|
||||
brighten: self.colorScheme == .light,
|
||||
onAllowOnce: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
|
||||
}
|
||||
},
|
||||
onAllowAlways: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
},
|
||||
onDeny: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
self.appModel.dismissPendingExecApprovalPrompt()
|
||||
})
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: 460)
|
||||
.transition(.scale(scale: 0.98).combined(with: .opacity))
|
||||
}
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExecApprovalPromptCard: View {
|
||||
let prompt: NodeAppModel.ExecApprovalPrompt
|
||||
let isResolving: Bool
|
||||
let errorText: String?
|
||||
let brighten: Bool
|
||||
let onAllowOnce: () -> Void
|
||||
let onAllowAlways: () -> Void
|
||||
let onDeny: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Exec approval required")
|
||||
.font(.headline)
|
||||
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.prompt.commandText)
|
||||
.font(.system(size: 15, weight: .regular, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let host = self.normalized(self.prompt.host) {
|
||||
ExecApprovalPromptMetadataRow(label: "Host", value: host)
|
||||
}
|
||||
if let nodeId = self.normalized(self.prompt.nodeId) {
|
||||
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
|
||||
}
|
||||
if let agentId = self.normalized(self.prompt.agentId) {
|
||||
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
|
||||
}
|
||||
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
|
||||
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorText = self.normalized(self.errorText) {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
if self.isResolving {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Resolving…")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
self.onAllowOnce()
|
||||
} label: {
|
||||
Text("Allow Once")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isResolving)
|
||||
|
||||
if self.prompt.allowsAllowAlways {
|
||||
Button {
|
||||
self.onAllowAlways()
|
||||
} label: {
|
||||
Text("Allow Always")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(role: .destructive) {
|
||||
self.onDeny()
|
||||
} label: {
|
||||
Text("Deny")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
|
||||
Button(role: .cancel) {
|
||||
self.onCancel()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
}
|
||||
}
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func expiresText(_ expiresAtMs: Int?) -> String? {
|
||||
guard let expiresAtMs else { return nil }
|
||||
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
|
||||
if remainingSeconds <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
if remainingSeconds < 60 {
|
||||
return "under a minute"
|
||||
}
|
||||
if remainingSeconds < 3600 {
|
||||
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
|
||||
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
|
||||
}
|
||||
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
|
||||
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExecApprovalPromptMetadataRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.value)
|
||||
.font(.footnote)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func execApprovalPromptDialog() -> some View {
|
||||
self.modifier(ExecApprovalPromptDialogModifier())
|
||||
}
|
||||
}
|
||||
@@ -61,11 +61,35 @@ final class NodeAppModel {
|
||||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
let commandText: String
|
||||
let allowedDecisions: [String]
|
||||
let host: String?
|
||||
let nodeId: String?
|
||||
let agentId: String?
|
||||
let expiresAtMs: Int?
|
||||
|
||||
var allowsAllowAlways: Bool {
|
||||
self.allowedDecisions.contains("allow-always")
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalResolutionOutcome {
|
||||
case resolved
|
||||
case stale
|
||||
case unavailable
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
case recording
|
||||
@@ -98,6 +122,10 @@ final class NodeAppModel {
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
|
||||
private(set) var pendingExecApprovalPromptResolving: Bool = false
|
||||
private(set) var pendingExecApprovalPromptErrorText: String?
|
||||
private var pendingExecApprovalPromptRequestGeneration: Int = 0
|
||||
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||
@@ -2607,6 +2635,19 @@ extension NodeAppModel {
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: userInfo,
|
||||
notificationCenter: self.notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
}
|
||||
self.execApprovalNotificationLogger.info(
|
||||
"Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
@@ -2779,6 +2820,216 @@ extension NodeAppModel {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private struct ExecApprovalGetRequest: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct ExecApprovalResolveRequest: Encodable {
|
||||
let id: String
|
||||
let decision: String
|
||||
}
|
||||
|
||||
private struct ExecApprovalGetResponse: Decodable {
|
||||
var id: String
|
||||
var commandText: String
|
||||
var allowedDecisions: [String]
|
||||
var host: String?
|
||||
var nodeId: String?
|
||||
var agentId: String?
|
||||
var expiresAtMs: Int?
|
||||
}
|
||||
|
||||
func presentExecApprovalNotificationPrompt(_ prompt: ExecApprovalNotificationPrompt) async {
|
||||
let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !approvalId.isEmpty else { return }
|
||||
|
||||
self.pendingExecApprovalPromptRequestGeneration &+= 1
|
||||
let requestGeneration = self.pendingExecApprovalPromptRequestGeneration
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
|
||||
let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId)
|
||||
guard self.pendingExecApprovalPromptRequestGeneration == requestGeneration else {
|
||||
return
|
||||
}
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
switch fetchedPrompt {
|
||||
case let .loaded(fetchedPrompt):
|
||||
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
|
||||
case .stale:
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
case let .failed(message):
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalPromptFetchOutcome {
|
||||
case loaded(ExecApprovalPrompt)
|
||||
case stale
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
self.pendingExecApprovalPrompt = prompt
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
|
||||
let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !approvalId.isEmpty, !commandText.isEmpty else { return nil }
|
||||
return ExecApprovalPrompt(
|
||||
id: approvalId,
|
||||
commandText: commandText,
|
||||
allowedDecisions: details.allowedDecisions.compactMap { decision in
|
||||
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
},
|
||||
host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
expiresAtMs: details.expiresAtMs)
|
||||
}
|
||||
|
||||
private func fetchExecApprovalPrompt(approvalId: String) async -> ExecApprovalPromptFetchOutcome {
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
guard connected else {
|
||||
return .failed(message: "operator_not_connected")
|
||||
}
|
||||
|
||||
do {
|
||||
let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId))
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "exec.approval.get",
|
||||
paramsJSON: payloadJSON,
|
||||
timeoutSeconds: 12)
|
||||
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
|
||||
guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
|
||||
return .failed(message: "invalid_prompt_payload")
|
||||
}
|
||||
return .loaded(prompt)
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
return .stale
|
||||
}
|
||||
return .failed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func dismissPendingExecApprovalPrompt() {
|
||||
self.pendingExecApprovalPrompt = nil
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
func dismissPendingExecApprovalPrompt(approvalId: String) {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
}
|
||||
|
||||
func resolvePendingExecApprovalPrompt(decision: String) async {
|
||||
guard let prompt = self.pendingExecApprovalPrompt else { return }
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedDecision.isEmpty else { return }
|
||||
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: prompt.id,
|
||||
decision: normalizedDecision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String
|
||||
) async -> ExecApprovalResolutionOutcome {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
|
||||
return .failed(message: "Invalid approval request.")
|
||||
}
|
||||
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
guard connected else {
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
|
||||
return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
|
||||
}
|
||||
|
||||
do {
|
||||
let payloadJSON = try Self.encodePayload(
|
||||
ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision))
|
||||
_ = try await self.operatorGateway.request(
|
||||
method: "exec.approval.resolve",
|
||||
paramsJSON: payloadJSON,
|
||||
timeoutSeconds: 12)
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .resolved
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .stale
|
||||
}
|
||||
if Self.isApprovalNotificationUnavailableError(error) {
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .unavailable
|
||||
}
|
||||
let logMessage =
|
||||
"Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)"
|
||||
self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
|
||||
return .failed(
|
||||
message: "OpenClaw couldn't resolve this approval right now. Try again.")
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
}
|
||||
if gatewayError.detailsReason == "APPROVAL_NOT_FOUND" {
|
||||
return true
|
||||
}
|
||||
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
}
|
||||
if gatewayError.detailsReason == "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE" {
|
||||
return true
|
||||
}
|
||||
return gatewayError.message.lowercased().contains("allow-always is unavailable")
|
||||
}
|
||||
|
||||
private struct SilentPushWakeAttemptResult {
|
||||
var applied: Bool
|
||||
var reason: String
|
||||
@@ -2790,14 +3041,69 @@ extension NodeAppModel {
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await self.isGatewayConnected() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return await self.isGatewayConnected()
|
||||
}
|
||||
|
||||
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
||||
let clampedTimeoutMs = max(0, timeoutMs)
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return await self.isOperatorConnected()
|
||||
}
|
||||
|
||||
private func ensureOperatorReconnectLoopIfNeeded() {
|
||||
guard let cfg = self.activeGatewayConnectConfig else {
|
||||
return
|
||||
}
|
||||
guard self.operatorGatewayTask == nil else {
|
||||
return
|
||||
}
|
||||
let stableID = cfg.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveStableID = stableID.isEmpty ? cfg.url.absoluteString : stableID
|
||||
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
self.startOperatorGatewayLoop(
|
||||
url: cfg.url,
|
||||
stableID: effectiveStableID,
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: cfg.nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
}
|
||||
self.ensureOperatorReconnectLoopIfNeeded()
|
||||
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
||||
}
|
||||
|
||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
||||
wakeId: String
|
||||
) async -> SilentPushWakeAttemptResult {
|
||||
@@ -3208,6 +3514,46 @@ extension NodeAppModel {
|
||||
includeApprovalScope: includeApprovalScope)
|
||||
}
|
||||
|
||||
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
self.presentFetchedExecApprovalPrompt(prompt)
|
||||
}
|
||||
|
||||
func _test_dismissPendingExecApprovalPrompt() {
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
|
||||
self.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
self.isApprovalNotificationStaleError(error)
|
||||
}
|
||||
|
||||
nonisolated static func _test_isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
self.isApprovalNotificationUnavailableError(error)
|
||||
}
|
||||
|
||||
static func _test_makeExecApprovalPrompt(
|
||||
id: String,
|
||||
commandText: String,
|
||||
allowedDecisions: [String],
|
||||
host: String?,
|
||||
nodeId: String?,
|
||||
agentId: String?,
|
||||
expiresAtMs: Int?
|
||||
) -> ExecApprovalPrompt? {
|
||||
self.makeExecApprovalPrompt(
|
||||
from: ExecApprovalGetResponse(
|
||||
id: id,
|
||||
commandText: commandText,
|
||||
allowedDecisions: allowedDecisions,
|
||||
host: host,
|
||||
nodeId: nodeId,
|
||||
agentId: agentId,
|
||||
expiresAtMs: expiresAtMs))
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ private struct PendingWatchPromptAction {
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
@@ -21,6 +23,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
|
||||
|
||||
weak var appModel: NodeAppModel? {
|
||||
didSet {
|
||||
@@ -44,6 +47,15 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalPrompts.isEmpty {
|
||||
let pending = self.pendingExecApprovalPrompts
|
||||
self.pendingExecApprovalPrompts.removeAll()
|
||||
Task { @MainActor in
|
||||
for prompt in pending {
|
||||
await model.presentExecApprovalNotificationPrompt(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +92,17 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
{
|
||||
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
|
||||
Task { @MainActor in
|
||||
let notificationCenter = LiveNotificationCenter()
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: userInfo,
|
||||
notificationCenter: notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.appModel?.dismissPendingExecApprovalPrompt(approvalId: approvalId)
|
||||
}
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
guard let appModel = self.appModel else {
|
||||
self.logger.info("APNs wake skipped: appModel unavailable")
|
||||
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
|
||||
@@ -216,6 +239,14 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalPrompt(
|
||||
from response: UNNotificationResponse) -> PendingExecApprovalPrompt?
|
||||
{
|
||||
ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
userInfo: response.notification.request.content.userInfo)
|
||||
}
|
||||
|
||||
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
|
||||
guard let appModel = self.appModel else {
|
||||
self.pendingWatchPromptActions.append(action)
|
||||
@@ -229,13 +260,25 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
|
||||
}
|
||||
|
||||
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
|
||||
guard let appModel = self.appModel else {
|
||||
self.pendingExecApprovalPrompts.append(prompt)
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
await appModel.presentExecApprovalNotificationPrompt(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
||||
{
|
||||
let userInfo = notification.request.content.userInfo
|
||||
if Self.isWatchPromptNotification(userInfo) {
|
||||
if Self.isWatchPromptNotification(userInfo)
|
||||
|| ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo)
|
||||
{
|
||||
completionHandler([.banner, .list, .sound])
|
||||
return
|
||||
}
|
||||
@@ -247,18 +290,29 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void)
|
||||
{
|
||||
guard let action = Self.parseWatchPromptAction(from: response) else {
|
||||
completionHandler()
|
||||
if let action = Self.parseWatchPromptAction(from: response) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
await self.routeWatchPromptAction(action)
|
||||
completionHandler()
|
||||
}
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
if let prompt = Self.parseExecApprovalPrompt(from: response) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
self.routeExecApprovalPrompt(prompt)
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
await self.routeWatchPromptAction(action)
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
92
apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift
Normal file
92
apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
enum ExecApprovalNotificationBridge {
|
||||
static let requestedKind = "exec.approval.requested"
|
||||
static let resolvedKind = "exec.approval.resolved"
|
||||
|
||||
private static let localRequestPrefix = "exec.approval."
|
||||
|
||||
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
self.payloadKind(userInfo: userInfo) == self.requestedKind
|
||||
}
|
||||
|
||||
static func parsePrompt(
|
||||
actionIdentifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier else { return nil }
|
||||
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
|
||||
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
|
||||
return ExecApprovalNotificationPrompt(approvalId: approvalId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering
|
||||
) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering
|
||||
) async {
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
await notificationCenter.removePendingNotificationRequests(
|
||||
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
|
||||
|
||||
let delivered = await notificationCenter.deliveredNotifications()
|
||||
let identifiers = delivered.compactMap { snapshot -> String? in
|
||||
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
|
||||
return snapshot.identifier
|
||||
}
|
||||
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func localRequestIdentifier(for approvalId: String) -> String {
|
||||
"\(self.localRequestPrefix)\(approvalId)"
|
||||
}
|
||||
|
||||
private static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
|
||||
if let payload = userInfo["openclaw"] as? [String: Any] {
|
||||
return payload
|
||||
}
|
||||
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
|
||||
return payload.reduce(into: [String: Any]()) { partialResult, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
partialResult[key] = pair.value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ struct RootCanvas: View {
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct NotificationSnapshot: @unchecked Sendable {
|
||||
let identifier: String
|
||||
let userInfo: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
@@ -13,6 +18,9 @@ protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
|
||||
func deliveredNotifications() async -> [NotificationSnapshot]
|
||||
}
|
||||
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
@@ -55,4 +63,27 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.center.getDeliveredNotifications { notifications in
|
||||
continuation.resume(
|
||||
returning: notifications.map { notification in
|
||||
NotificationSnapshot(
|
||||
identifier: notification.request.identifier,
|
||||
userInfo: notification.request.content.userInfo)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift
Normal file
86
apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import UserNotifications
|
||||
@testable import OpenClaw
|
||||
|
||||
private final class MockNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
var authorization: NotificationAuthorizationStatus = .authorized
|
||||
var addedRequests: [UNNotificationRequest] = []
|
||||
var pendingRemovedIdentifiers: [[String]] = []
|
||||
var deliveredRemovedIdentifiers: [[String]] = []
|
||||
var delivered: [NotificationSnapshot] = []
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
self.authorization
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
self.addedRequests.append(request)
|
||||
}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
|
||||
self.pendingRemovedIdentifiers.append(identifiers)
|
||||
}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
|
||||
self.deliveredRemovedIdentifiers.append(identifiers)
|
||||
}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
self.delivered
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
|
||||
@Test func parsePromptMapsDefaultNotificationTap() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
|
||||
}
|
||||
|
||||
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
|
||||
let center = MockNotificationCenter()
|
||||
center.delivered = [
|
||||
NotificationSnapshot(
|
||||
identifier: "remote-approval-1",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
]),
|
||||
NotificationSnapshot(
|
||||
identifier: "remote-other",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-999",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.resolvedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
],
|
||||
notificationCenter: center)
|
||||
|
||||
#expect(handled)
|
||||
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
|
||||
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,14 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
}
|
||||
|
||||
func add(_: UNNotificationRequest) async throws {}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@@ -119,6 +127,79 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.mainSessionKey == "agent:agent-123:main")
|
||||
}
|
||||
|
||||
@Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-1",
|
||||
commandText: "echo first",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: "main",
|
||||
expiresAtMs: 1)))
|
||||
|
||||
let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(firstPrompt.id == "approval-1")
|
||||
#expect(firstPrompt.commandText == "echo first")
|
||||
#expect(firstPrompt.allowsAllowAlways == false)
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-2",
|
||||
commandText: "echo second",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: "node-2",
|
||||
agentId: nil,
|
||||
expiresAtMs: 2)))
|
||||
|
||||
let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(secondPrompt.id == "approval-2")
|
||||
#expect(secondPrompt.commandText == "echo second")
|
||||
#expect(secondPrompt.allowsAllowAlways)
|
||||
|
||||
appModel._test_dismissPendingExecApprovalPrompt()
|
||||
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func dismissPendingExecApprovalPromptByIdLeavesDifferentPromptVisible() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-active",
|
||||
commandText: "echo keep",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: 1)))
|
||||
|
||||
appModel.dismissPendingExecApprovalPrompt(approvalId: "approval-stale")
|
||||
|
||||
let prompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(prompt.id == "approval-active")
|
||||
}
|
||||
|
||||
@Test func approvalNotificationErrorClassificationPrefersStructuredDetails() {
|
||||
let staleError = GatewayResponseError(
|
||||
method: "exec.approval.get",
|
||||
code: "INVALID_REQUEST",
|
||||
message: "gateway error",
|
||||
details: ["reason": AnyCodable("APPROVAL_NOT_FOUND")])
|
||||
let unavailableError = GatewayResponseError(
|
||||
method: "exec.approval.resolve",
|
||||
code: "INVALID_REQUEST",
|
||||
message: "gateway error",
|
||||
details: ["reason": AnyCodable("APPROVAL_ALLOW_ALWAYS_UNAVAILABLE")])
|
||||
|
||||
#expect(NodeAppModel._test_isApprovalNotificationStaleError(staleError))
|
||||
#expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError))
|
||||
}
|
||||
|
||||
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
|
||||
@@ -127,6 +127,12 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
public var detailsReason: String? {
|
||||
let raw = self.details["reason"]?.value as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
|
||||
360
src/gateway/exec-approval-ios-push.test.ts
Normal file
360
src/gateway/exec-approval-ios-push.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listDevicePairingMock = vi.fn();
|
||||
const loadApnsRegistrationMock = vi.fn();
|
||||
const resolveApnsAuthConfigFromEnvMock = vi.fn();
|
||||
const resolveApnsRelayConfigFromEnvMock = vi.fn();
|
||||
const sendApnsExecApprovalAlertMock = vi.fn();
|
||||
const sendApnsExecApprovalResolvedWakeMock = vi.fn();
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({ gateway: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/device-pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../infra/device-pairing.js")>(
|
||||
"../infra/device-pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listDevicePairing: listDevicePairingMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/push-apns.js", () => ({
|
||||
loadApnsRegistration: loadApnsRegistrationMock,
|
||||
resolveApnsAuthConfigFromEnv: resolveApnsAuthConfigFromEnvMock,
|
||||
resolveApnsRelayConfigFromEnv: resolveApnsRelayConfigFromEnvMock,
|
||||
sendApnsExecApprovalAlert: sendApnsExecApprovalAlertMock,
|
||||
sendApnsExecApprovalResolvedWake: sendApnsExecApprovalResolvedWakeMock,
|
||||
clearApnsRegistrationIfCurrent: vi.fn(),
|
||||
shouldClearStoredApnsRegistration: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("createExecApprovalIosPushDelivery", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
listDevicePairingMock.mockResolvedValue({ pending: [], paired: [] });
|
||||
loadApnsRegistrationMock.mockResolvedValue({
|
||||
nodeId: "ios-device-1",
|
||||
transport: "direct",
|
||||
token: "apns-token",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
resolveApnsAuthConfigFromEnvMock.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { teamId: "team", keyId: "key", privateKey: "private-key" },
|
||||
});
|
||||
resolveApnsRelayConfigFromEnvMock.mockReturnValue({ ok: false, error: "unused" });
|
||||
sendApnsExecApprovalAlertMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
environment: "sandbox",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
tokenSuffix: "token",
|
||||
transport: "direct",
|
||||
});
|
||||
sendApnsExecApprovalResolvedWakeMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
environment: "sandbox",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
tokenSuffix: "token",
|
||||
transport: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not target iOS devices whose active operator token lacks operator.approvals", async () => {
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub",
|
||||
platform: "iOS 18",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
approvedScopes: ["operator.approvals"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 1,
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
|
||||
const accepted = await delivery.handleRequested({
|
||||
id: "approval-1",
|
||||
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("targets iOS devices when the active operator token includes operator.approvals", async () => {
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub",
|
||||
platform: "iOS 18",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 1,
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.approvals", "operator.read"],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
|
||||
const accepted = await delivery.handleRequested({
|
||||
id: "approval-2",
|
||||
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
|
||||
expect(accepted).toBe(true);
|
||||
expect(loadApnsRegistrationMock).toHaveBeenCalledWith("ios-device-1");
|
||||
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not treat iOS as a live approval route when every push fails", async () => {
|
||||
const warn = vi.fn();
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub",
|
||||
platform: "iOS 18",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 1,
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.approvals", "operator.read"],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
sendApnsExecApprovalAlertMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 410,
|
||||
reason: "Unregistered",
|
||||
environment: "sandbox",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
tokenSuffix: "token",
|
||||
transport: "direct",
|
||||
});
|
||||
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: { warn } });
|
||||
|
||||
const accepted = await delivery.handleRequested({
|
||||
id: "approval-dead-route",
|
||||
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"exec approvals: iOS request push failed node=ios-device-1 status=410 reason=Unregistered",
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"exec approvals: iOS request push reached no devices approvalId=approval-dead-route attempted=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for request delivery to finish before sending cleanup pushes", async () => {
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub",
|
||||
platform: "iOS 18",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 1,
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.approvals", "operator.read"],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const requestedPush = createDeferred<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
environment: string;
|
||||
topic: string;
|
||||
tokenSuffix: string;
|
||||
transport: string;
|
||||
}>();
|
||||
sendApnsExecApprovalAlertMock.mockReturnValue(requestedPush.promise);
|
||||
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
|
||||
const requested = delivery.handleRequested({
|
||||
id: "approval-ordered-cleanup",
|
||||
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
const resolved = delivery.handleResolved({
|
||||
id: "approval-ordered-cleanup",
|
||||
decision: "allow-once",
|
||||
ts: 1,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(sendApnsExecApprovalResolvedWakeMock).not.toHaveBeenCalled();
|
||||
|
||||
requestedPush.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
environment: "sandbox",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
tokenSuffix: "token",
|
||||
transport: "direct",
|
||||
});
|
||||
await requested;
|
||||
await resolved;
|
||||
|
||||
expect(sendApnsExecApprovalResolvedWakeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips cleanup pushes when the original request target set is unknown", async () => {
|
||||
const debug = vi.fn();
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: { debug } });
|
||||
|
||||
await delivery.handleResolved({
|
||||
id: "approval-missing-targets",
|
||||
decision: "allow-once",
|
||||
ts: 1,
|
||||
});
|
||||
|
||||
expect(debug).toHaveBeenCalledWith(
|
||||
"exec approvals: iOS cleanup push skipped approvalId=approval-missing-targets reason=missing-targets",
|
||||
);
|
||||
expect(listDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
|
||||
expect(sendApnsExecApprovalResolvedWakeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends cleanup pushes only to the original request targets", async () => {
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "ios-device-1",
|
||||
publicKey: "pub",
|
||||
platform: "iOS 18",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
createdAtMs: 1,
|
||||
approvedAtMs: 1,
|
||||
tokens: {
|
||||
operator: {
|
||||
token: "operator-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.approvals", "operator.read"],
|
||||
createdAtMs: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
|
||||
const delivery = createExecApprovalIosPushDelivery({ log: {} });
|
||||
|
||||
await delivery.handleRequested({
|
||||
id: "approval-cleanup",
|
||||
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
loadApnsRegistrationMock.mockResolvedValue({
|
||||
nodeId: "ios-device-1",
|
||||
transport: "direct",
|
||||
token: "apns-token",
|
||||
topic: "ai.openclaw.ios.test",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
resolveApnsAuthConfigFromEnvMock.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { teamId: "team", keyId: "key", privateKey: "private-key" },
|
||||
});
|
||||
|
||||
await delivery.handleResolved({
|
||||
id: "approval-cleanup",
|
||||
decision: "allow-once",
|
||||
ts: 1,
|
||||
});
|
||||
|
||||
expect(listDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(loadApnsRegistrationMock).toHaveBeenCalledWith("ios-device-1");
|
||||
expect(sendApnsExecApprovalResolvedWakeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
367
src/gateway/exec-approval-ios-push.ts
Normal file
367
src/gateway/exec-approval-ios-push.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
hasEffectivePairedDeviceRole,
|
||||
listDevicePairing,
|
||||
type DeviceAuthToken,
|
||||
type PairedDevice,
|
||||
} from "../infra/device-pairing.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
|
||||
import {
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
sendApnsExecApprovalAlert,
|
||||
sendApnsExecApprovalResolvedWake,
|
||||
shouldClearStoredApnsRegistration,
|
||||
type ApnsAuthConfig,
|
||||
type ApnsRegistration,
|
||||
type ApnsRelayConfig,
|
||||
} from "../infra/push-apns.js";
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
|
||||
const APPROVALS_SCOPE = "operator.approvals";
|
||||
const OPERATOR_ROLE = "operator";
|
||||
|
||||
type GatewayLikeLogger = {
|
||||
debug?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
type DeliveryTarget = {
|
||||
nodeId: string;
|
||||
registration: ApnsRegistration;
|
||||
};
|
||||
|
||||
type DeliveryPlan = {
|
||||
targets: DeliveryTarget[];
|
||||
directAuth?: ApnsAuthConfig;
|
||||
relayConfig?: ApnsRelayConfig;
|
||||
};
|
||||
|
||||
type ApprovalDeliveryState = {
|
||||
nodeIds: string[];
|
||||
requestPushPromise: Promise<{ attempted: number; delivered: number }>;
|
||||
};
|
||||
|
||||
function isIosPlatform(platform: string | undefined): boolean {
|
||||
const normalized = platform?.trim().toLowerCase() ?? "";
|
||||
return normalized.startsWith("ios") || normalized.startsWith("ipados");
|
||||
}
|
||||
|
||||
function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | null {
|
||||
const operatorToken = device.tokens?.[OPERATOR_ROLE];
|
||||
if (!operatorToken || operatorToken.revokedAtMs) {
|
||||
return null;
|
||||
}
|
||||
return operatorToken;
|
||||
}
|
||||
|
||||
function canApproveExecRequests(device: PairedDevice): boolean {
|
||||
const operatorToken = resolveActiveOperatorToken(device);
|
||||
if (!operatorToken) {
|
||||
return false;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: [APPROVALS_SCOPE],
|
||||
allowedScopes: operatorToken.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldTargetDevice(params: {
|
||||
device: PairedDevice;
|
||||
requireApprovalScope: boolean;
|
||||
}): boolean {
|
||||
if (!isIosPlatform(params.device.platform)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasEffectivePairedDeviceRole(params.device, OPERATOR_ROLE)) {
|
||||
return false;
|
||||
}
|
||||
if (!params.requireApprovalScope) {
|
||||
return true;
|
||||
}
|
||||
return canApproveExecRequests(params.device);
|
||||
}
|
||||
|
||||
async function loadRegisteredTargets(params: {
|
||||
deviceIds: readonly string[];
|
||||
}): Promise<DeliveryTarget[]> {
|
||||
const targets = await Promise.all(
|
||||
params.deviceIds.map(async (nodeId) => {
|
||||
const registration = await loadApnsRegistration(nodeId);
|
||||
return registration ? { nodeId, registration } : null;
|
||||
}),
|
||||
);
|
||||
return targets.filter((target): target is DeliveryTarget => target !== null);
|
||||
}
|
||||
|
||||
async function resolvePairedTargets(params: {
|
||||
requireApprovalScope: boolean;
|
||||
}): Promise<DeliveryTarget[]> {
|
||||
const pairing = await listDevicePairing();
|
||||
const deviceIds = pairing.paired
|
||||
.filter((device) =>
|
||||
shouldTargetDevice({ device, requireApprovalScope: params.requireApprovalScope }),
|
||||
)
|
||||
.map((device) => device.deviceId);
|
||||
return await loadRegisteredTargets({ deviceIds });
|
||||
}
|
||||
|
||||
async function resolveDeliveryPlan(params: {
|
||||
requireApprovalScope: boolean;
|
||||
explicitNodeIds?: readonly string[];
|
||||
log: GatewayLikeLogger;
|
||||
}): Promise<DeliveryPlan> {
|
||||
const targets = params.explicitNodeIds?.length
|
||||
? await loadRegisteredTargets({ deviceIds: params.explicitNodeIds })
|
||||
: await resolvePairedTargets({ requireApprovalScope: params.requireApprovalScope });
|
||||
if (targets.length === 0) {
|
||||
return { targets: [] };
|
||||
}
|
||||
|
||||
const needsDirect = targets.some((target) => target.registration.transport === "direct");
|
||||
const needsRelay = targets.some((target) => target.registration.transport === "relay");
|
||||
|
||||
let directAuth: ApnsAuthConfig | undefined;
|
||||
if (needsDirect) {
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
if (auth.ok) {
|
||||
directAuth = auth.value;
|
||||
} else {
|
||||
params.log.warn?.(`exec approvals: iOS direct APNs auth unavailable: ${auth.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
let relayConfig: ApnsRelayConfig | undefined;
|
||||
if (needsRelay) {
|
||||
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
|
||||
if (relay.ok) {
|
||||
relayConfig = relay.value;
|
||||
} else {
|
||||
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targets: targets.filter((target) =>
|
||||
target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig),
|
||||
),
|
||||
directAuth,
|
||||
relayConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function clearStaleApnsRegistrationIfNeeded(params: {
|
||||
nodeId: string;
|
||||
registration: ApnsRegistration;
|
||||
result: { status: number; reason?: string };
|
||||
}): Promise<void> {
|
||||
if (
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: params.registration,
|
||||
result: params.result,
|
||||
})
|
||||
) {
|
||||
await clearApnsRegistrationIfCurrent({
|
||||
nodeId: params.nodeId,
|
||||
registration: params.registration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequestedPushes(params: {
|
||||
request: ExecApprovalRequest;
|
||||
plan: DeliveryPlan;
|
||||
log: GatewayLikeLogger;
|
||||
}): Promise<{ attempted: number; delivered: number }> {
|
||||
const results = await Promise.allSettled(
|
||||
params.plan.targets.map(async (target) => {
|
||||
const result =
|
||||
target.registration.transport === "direct"
|
||||
? await sendApnsExecApprovalAlert({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId: params.request.id,
|
||||
auth: params.plan.directAuth!,
|
||||
})
|
||||
: await sendApnsExecApprovalAlert({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId: params.request.id,
|
||||
relayConfig: params.plan.relayConfig!,
|
||||
});
|
||||
await clearStaleApnsRegistrationIfNeeded({
|
||||
nodeId: target.nodeId,
|
||||
registration: target.registration,
|
||||
result,
|
||||
});
|
||||
if (!result.ok) {
|
||||
params.log.warn?.(
|
||||
`exec approvals: iOS request push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return { nodeId: target.nodeId, ok: result.ok };
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
const message =
|
||||
result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
params.log.warn?.(`exec approvals: iOS request push threw error: ${message}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
attempted: params.plan.targets.length,
|
||||
delivered: results.filter((result) => result.status === "fulfilled" && result.value.ok).length,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendResolvedPushes(params: {
|
||||
approvalId: string;
|
||||
plan: DeliveryPlan;
|
||||
log: GatewayLikeLogger;
|
||||
}): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
params.plan.targets.map(async (target) => {
|
||||
const result =
|
||||
target.registration.transport === "direct"
|
||||
? await sendApnsExecApprovalResolvedWake({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
auth: params.plan.directAuth!,
|
||||
})
|
||||
: await sendApnsExecApprovalResolvedWake({
|
||||
registration: target.registration,
|
||||
nodeId: target.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
relayConfig: params.plan.relayConfig!,
|
||||
});
|
||||
await clearStaleApnsRegistrationIfNeeded({
|
||||
nodeId: target.nodeId,
|
||||
registration: target.registration,
|
||||
result,
|
||||
});
|
||||
if (!result.ok) {
|
||||
params.log.warn?.(
|
||||
`exec approvals: iOS cleanup push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
|
||||
const approvalDeliveriesById = new Map<string, ApprovalDeliveryState>();
|
||||
const pendingDeliveryStateById = new Map<string, Promise<ApprovalDeliveryState | null>>();
|
||||
|
||||
return {
|
||||
async handleRequested(request: ExecApprovalRequest): Promise<boolean> {
|
||||
const deliveryStatePromise = (async (): Promise<ApprovalDeliveryState | null> => {
|
||||
const plan = await resolveDeliveryPlan({
|
||||
requireApprovalScope: true,
|
||||
log: params.log,
|
||||
});
|
||||
if (plan.targets.length === 0) {
|
||||
approvalDeliveriesById.delete(request.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const deliveryState: ApprovalDeliveryState = {
|
||||
nodeIds: plan.targets.map((target) => target.nodeId),
|
||||
requestPushPromise: sendRequestedPushes({ request, plan, log: params.log }).catch(
|
||||
(err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
params.log.error?.(`exec approvals: iOS request push failed: ${message}`);
|
||||
return { attempted: plan.targets.length, delivered: 0 };
|
||||
},
|
||||
),
|
||||
};
|
||||
approvalDeliveriesById.set(request.id, deliveryState);
|
||||
return deliveryState;
|
||||
})();
|
||||
pendingDeliveryStateById.set(request.id, deliveryStatePromise);
|
||||
|
||||
const deliveryState = await deliveryStatePromise;
|
||||
if (pendingDeliveryStateById.get(request.id) === deliveryStatePromise) {
|
||||
pendingDeliveryStateById.delete(request.id);
|
||||
}
|
||||
if (!deliveryState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { attempted, delivered } = await deliveryState.requestPushPromise;
|
||||
if (attempted > 0 && delivered === 0) {
|
||||
params.log.warn?.(
|
||||
`exec approvals: iOS request push reached no devices approvalId=${request.id} attempted=${attempted}`,
|
||||
);
|
||||
if (
|
||||
approvalDeliveriesById.get(request.id)?.requestPushPromise ===
|
||||
deliveryState.requestPushPromise
|
||||
) {
|
||||
approvalDeliveriesById.delete(request.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||
const deliveryState =
|
||||
approvalDeliveriesById.get(resolved.id) ??
|
||||
(await pendingDeliveryStateById.get(resolved.id));
|
||||
approvalDeliveriesById.delete(resolved.id);
|
||||
pendingDeliveryStateById.delete(resolved.id);
|
||||
if (!deliveryState?.nodeIds.length) {
|
||||
params.log.debug?.(
|
||||
`exec approvals: iOS cleanup push skipped approvalId=${resolved.id} reason=missing-targets`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deliveryState.requestPushPromise;
|
||||
const plan = await resolveDeliveryPlan({
|
||||
requireApprovalScope: false,
|
||||
explicitNodeIds: deliveryState.nodeIds,
|
||||
log: params.log,
|
||||
});
|
||||
if (plan.targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
await sendResolvedPushes({
|
||||
approvalId: resolved.id,
|
||||
plan,
|
||||
log: params.log,
|
||||
});
|
||||
},
|
||||
|
||||
async handleExpired(request: ExecApprovalRequest): Promise<void> {
|
||||
const deliveryState =
|
||||
approvalDeliveriesById.get(request.id) ?? (await pendingDeliveryStateById.get(request.id));
|
||||
approvalDeliveriesById.delete(request.id);
|
||||
pendingDeliveryStateById.delete(request.id);
|
||||
if (!deliveryState?.nodeIds.length) {
|
||||
params.log.debug?.(
|
||||
`exec approvals: iOS cleanup push skipped approvalId=${request.id} reason=missing-targets`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deliveryState.requestPushPromise;
|
||||
const plan = await resolveDeliveryPlan({
|
||||
requireApprovalScope: false,
|
||||
explicitNodeIds: deliveryState.nodeIds,
|
||||
log: params.log,
|
||||
});
|
||||
if (plan.targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
await sendResolvedPushes({
|
||||
approvalId: request.id,
|
||||
plan,
|
||||
log: params.log,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,12 +97,18 @@ describe("operator scope authorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires approvals scope for approval methods", () => {
|
||||
expect(authorizeOperatorScopesForMethod("exec.approval.resolve", ["operator.write"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.approvals",
|
||||
});
|
||||
});
|
||||
it.each(["exec.approval.get", "exec.approval.resolve"])(
|
||||
"requires approvals scope for %s",
|
||||
(method) => {
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.write"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.approvals",
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.approvals"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["plugin.approval.request", "plugin.approval.waitDecision", "plugin.approval.resolve"])(
|
||||
"requires approvals scope for %s",
|
||||
|
||||
@@ -41,6 +41,7 @@ const NODE_ROLE_METHODS = new Set([
|
||||
|
||||
const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
[APPROVALS_SCOPE]: [
|
||||
"exec.approval.get",
|
||||
"exec.approval.request",
|
||||
"exec.approval.waitDecision",
|
||||
"exec.approval.resolve",
|
||||
|
||||
@@ -120,6 +120,8 @@ import {
|
||||
type ExecApprovalsSetParams,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
type ExecApprovalsSnapshot,
|
||||
type ExecApprovalGetParams,
|
||||
ExecApprovalGetParamsSchema,
|
||||
type ExecApprovalRequestParams,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
type ExecApprovalResolveParams,
|
||||
@@ -445,6 +447,9 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
|
||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||
ExecApprovalsSetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalGetParams = ajv.compile<ExecApprovalGetParams>(
|
||||
ExecApprovalGetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequestParams>(
|
||||
ExecApprovalRequestParamsSchema,
|
||||
);
|
||||
@@ -615,6 +620,11 @@ export {
|
||||
CronRunsParamsSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
ExecApprovalsGetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
@@ -736,6 +746,9 @@ export type {
|
||||
ExecApprovalsGetParams,
|
||||
ExecApprovalsSetParams,
|
||||
ExecApprovalsSnapshot,
|
||||
ExecApprovalGetParams,
|
||||
ExecApprovalRequestParams,
|
||||
ExecApprovalResolveParams,
|
||||
LogsTailParams,
|
||||
LogsTailResult,
|
||||
PollParams,
|
||||
|
||||
@@ -86,6 +86,13 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalGetParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(NonEmptyString),
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSetParamsSchema,
|
||||
ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -312,6 +313,7 @@ export const ProtocolSchemas = {
|
||||
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalGetParams: ExecApprovalGetParamsSchema,
|
||||
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,
|
||||
|
||||
@@ -130,6 +130,7 @@ export type ExecApprovalsSetParams = SchemaType<"ExecApprovalsSetParams">;
|
||||
export type ExecApprovalsNodeGetParams = SchemaType<"ExecApprovalsNodeGetParams">;
|
||||
export type ExecApprovalsNodeSetParams = SchemaType<"ExecApprovalsNodeSetParams">;
|
||||
export type ExecApprovalsSnapshot = SchemaType<"ExecApprovalsSnapshot">;
|
||||
export type ExecApprovalGetParams = SchemaType<"ExecApprovalGetParams">;
|
||||
export type ExecApprovalRequestParams = SchemaType<"ExecApprovalRequestParams">;
|
||||
export type ExecApprovalResolveParams = SchemaType<"ExecApprovalResolveParams">;
|
||||
export type PluginApprovalRequestParams = SchemaType<"PluginApprovalRequestParams">;
|
||||
|
||||
@@ -26,6 +26,7 @@ const BASE_METHODS = [
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
"exec.approval.get",
|
||||
"exec.approval.request",
|
||||
"exec.approval.waitDecision",
|
||||
"exec.approval.resolve",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
|
||||
import { sanitizeExecApprovalDisplayText } from "../../infra/exec-approval-command-display.js";
|
||||
import {
|
||||
resolveExecApprovalCommandDisplay,
|
||||
sanitizeExecApprovalDisplayText,
|
||||
} from "../../infra/exec-approval-command-display.js";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import {
|
||||
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
|
||||
resolveExecApprovalAllowedDecisions,
|
||||
resolveExecApprovalRequestAllowedDecisions,
|
||||
type ExecApprovalDecision,
|
||||
type ExecApprovalRequest,
|
||||
type ExecApprovalResolved,
|
||||
} from "../../infra/exec-approvals.js";
|
||||
import {
|
||||
buildSystemRunApprovalBinding,
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateExecApprovalGetParams,
|
||||
validateExecApprovalRequestParams,
|
||||
validateExecApprovalResolveParams,
|
||||
} from "../protocol/index.js";
|
||||
@@ -26,11 +32,90 @@ const APPROVAL_NOT_FOUND_DETAILS = {
|
||||
reason: ErrorCodes.APPROVAL_NOT_FOUND,
|
||||
} as const;
|
||||
|
||||
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
|
||||
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
type ExecApprovalIosPushDelivery = {
|
||||
handleRequested?: (request: ExecApprovalRequest) => Promise<boolean>;
|
||||
handleResolved?: (resolved: ExecApprovalResolved) => Promise<void>;
|
||||
handleExpired?: (request: ExecApprovalRequest) => Promise<void>;
|
||||
};
|
||||
|
||||
function resolvePendingApprovalRecord(manager: ExecApprovalManager, inputId: string) {
|
||||
const resolvedId = manager.lookupPendingId(inputId);
|
||||
if (resolvedId.kind === "none") {
|
||||
return { ok: false as const, response: "missing" as const };
|
||||
}
|
||||
if (resolvedId.kind === "ambiguous") {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "ambiguous approval id prefix; use the full id",
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = manager.getSnapshot(resolvedId.id);
|
||||
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
|
||||
return { ok: false as const, response: "missing" as const };
|
||||
}
|
||||
return { ok: true as const, approvalId: resolvedId.id, snapshot };
|
||||
}
|
||||
|
||||
export function createExecApprovalHandlers(
|
||||
manager: ExecApprovalManager,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery },
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"exec.approval.get": async ({ params, respond }) => {
|
||||
if (!validateExecApprovalGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid exec.approval.get params: ${formatValidationErrors(
|
||||
validateExecApprovalGetParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as { id: string };
|
||||
const resolved = resolvePendingApprovalRecord(manager, p.id);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.response === "missing") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
|
||||
return;
|
||||
}
|
||||
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(
|
||||
resolved.snapshot.request,
|
||||
);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
id: resolved.approvalId,
|
||||
commandText,
|
||||
commandPreview,
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(resolved.snapshot.request),
|
||||
host: resolved.snapshot.request.host ?? null,
|
||||
nodeId: resolved.snapshot.request.nodeId ?? null,
|
||||
agentId: resolved.snapshot.request.agentId ?? null,
|
||||
expiresAtMs: resolved.snapshot.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"exec.approval.request": async ({ params, respond, context, client }) => {
|
||||
if (!validateExecApprovalRequestParams(params)) {
|
||||
respond(
|
||||
@@ -181,16 +266,13 @@ export function createExecApprovalHandlers(
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"exec.approval.requested",
|
||||
{
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const requestEvent: ExecApprovalRequest = {
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
};
|
||||
context.broadcast("exec.approval.requested", requestEvent, { dropIfSlow: true });
|
||||
const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
|
||||
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
|
||||
turnSourceChannel: record.request.turnSourceChannel,
|
||||
@@ -199,18 +281,21 @@ export function createExecApprovalHandlers(
|
||||
let forwarded = false;
|
||||
if (opts?.forwarder) {
|
||||
try {
|
||||
forwarded = await opts.forwarder.handleRequested({
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
});
|
||||
forwarded = await opts.forwarder.handleRequested(requestEvent);
|
||||
} catch (err) {
|
||||
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
let deliveredToIosPush = false;
|
||||
if (opts?.iosPushDelivery?.handleRequested) {
|
||||
try {
|
||||
deliveredToIosPush = await opts.iosPushDelivery.handleRequested(requestEvent);
|
||||
} catch (err) {
|
||||
context.logGateway?.error?.(`exec approvals: iOS push request failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute) {
|
||||
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute && !deliveredToIosPush) {
|
||||
manager.expire(record.id, "no-approval-route");
|
||||
respond(
|
||||
true,
|
||||
@@ -241,6 +326,11 @@ export function createExecApprovalHandlers(
|
||||
}
|
||||
|
||||
const decision = await decisionPromise;
|
||||
if (decision === null) {
|
||||
void opts?.iosPushDelivery?.handleExpired?.(requestEvent).catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: iOS push expire failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
// Send final response with decision for callers using expectFinal:true.
|
||||
respond(
|
||||
true,
|
||||
@@ -304,32 +394,23 @@ export function createExecApprovalHandlers(
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||
return;
|
||||
}
|
||||
const resolvedId = manager.lookupPendingId(p.id);
|
||||
if (resolvedId.kind === "none") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
const resolved = resolvePendingApprovalRecord(manager, p.id);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.response === "missing") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
|
||||
return;
|
||||
}
|
||||
if (resolvedId.kind === "ambiguous") {
|
||||
const candidates = resolvedId.ids.slice(0, 3).join(", ");
|
||||
const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : "";
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const approvalId = resolvedId.id;
|
||||
const snapshot = manager.getSnapshot(approvalId);
|
||||
const approvalId = resolved.approvalId;
|
||||
const snapshot = resolved.snapshot;
|
||||
const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot?.request);
|
||||
if (snapshot && !allowedDecisions.includes(decision)) {
|
||||
respond(
|
||||
@@ -338,6 +419,9 @@ export function createExecApprovalHandlers(
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"allow-always is unavailable because the effective policy requires approval every time",
|
||||
{
|
||||
details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS,
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -354,22 +438,20 @@ export function createExecApprovalHandlers(
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"exec.approval.resolved",
|
||||
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
void opts?.forwarder
|
||||
?.handleResolved({
|
||||
id: approvalId,
|
||||
decision,
|
||||
resolvedBy,
|
||||
ts: Date.now(),
|
||||
request: snapshot?.request,
|
||||
})
|
||||
.catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
|
||||
});
|
||||
const resolvedEvent: ExecApprovalResolved = {
|
||||
id: approvalId,
|
||||
decision,
|
||||
resolvedBy,
|
||||
ts: Date.now(),
|
||||
request: snapshot?.request,
|
||||
};
|
||||
context.broadcast("exec.approval.resolved", resolvedEvent, { dropIfSlow: true });
|
||||
void opts?.forwarder?.handleResolved(resolvedEvent).catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
|
||||
});
|
||||
void opts?.iosPushDelivery?.handleResolved?.(resolvedEvent).catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: iOS push resolve failed: ${String(err)}`);
|
||||
});
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -331,6 +331,7 @@ describe("gateway chat transcript writes (guardrail)", () => {
|
||||
describe("exec approval handlers", () => {
|
||||
const execApprovalNoop = () => false;
|
||||
type ExecApprovalHandlers = ReturnType<typeof createExecApprovalHandlers>;
|
||||
type ExecApprovalGetArgs = Parameters<ExecApprovalHandlers["exec.approval.get"]>[0];
|
||||
type ExecApprovalRequestArgs = Parameters<ExecApprovalHandlers["exec.approval.request"]>[0];
|
||||
type ExecApprovalResolveArgs = Parameters<ExecApprovalHandlers["exec.approval.resolve"]>[0];
|
||||
|
||||
@@ -363,6 +364,21 @@ describe("exec approval handlers", () => {
|
||||
return context as unknown as ExecApprovalResolveArgs["context"];
|
||||
}
|
||||
|
||||
async function getExecApproval(params: {
|
||||
handlers: ExecApprovalHandlers;
|
||||
id: string;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
return params.handlers["exec.approval.get"]({
|
||||
params: { id: params.id } as ExecApprovalGetArgs["params"],
|
||||
respond: params.respond as unknown as ExecApprovalGetArgs["respond"],
|
||||
context: {} as ExecApprovalGetArgs["context"],
|
||||
client: null,
|
||||
req: { id: "req-get", type: "req", method: "exec.approval.get" },
|
||||
isWebchatConnect: execApprovalNoop,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestExecApproval(params: {
|
||||
handlers: ExecApprovalHandlers;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
@@ -451,20 +467,36 @@ describe("exec approval handlers", () => {
|
||||
return { handlers, broadcasts, respond, context };
|
||||
}
|
||||
|
||||
function createForwardingExecApprovalFixture() {
|
||||
function createForwardingExecApprovalFixture(opts?: {
|
||||
iosPushDelivery?: {
|
||||
handleRequested: ReturnType<typeof vi.fn>;
|
||||
handleResolved: ReturnType<typeof vi.fn>;
|
||||
handleExpired: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
const manager = new ExecApprovalManager();
|
||||
const forwarder = {
|
||||
handleRequested: vi.fn(async () => false),
|
||||
handleResolved: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const handlers = createExecApprovalHandlers(manager, { forwarder });
|
||||
const handlers = createExecApprovalHandlers(manager, {
|
||||
forwarder,
|
||||
iosPushDelivery: opts?.iosPushDelivery as never,
|
||||
});
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (_event: string, _payload: unknown) => {},
|
||||
hasExecApprovalClients: () => false,
|
||||
};
|
||||
return { manager, handlers, forwarder, respond, context };
|
||||
return {
|
||||
manager,
|
||||
handlers,
|
||||
forwarder,
|
||||
iosPushDelivery: opts?.iosPushDelivery,
|
||||
respond,
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function drainApprovalRequestTicks() {
|
||||
@@ -530,6 +562,86 @@ describe("exec approval handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns pending approval details for exec.approval.get", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
twoPhase: true,
|
||||
host: "gateway",
|
||||
command: "echo ok",
|
||||
commandArgv: ["echo", "ok"],
|
||||
systemRunPlan: undefined,
|
||||
nodeId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||
const id = (requested?.payload as { id?: string })?.id ?? "";
|
||||
expect(id).not.toBe("");
|
||||
|
||||
const getRespond = vi.fn();
|
||||
await getExecApproval({ handlers, id, respond: getRespond });
|
||||
|
||||
expect(getRespond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
id,
|
||||
commandText: "echo ok",
|
||||
allowedDecisions: expect.arrayContaining(["allow-once", "allow-always", "deny"]),
|
||||
host: "gateway",
|
||||
nodeId: null,
|
||||
agentId: null,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
it("returns not found for stale exec.approval.get ids", async () => {
|
||||
const { handlers, respond, context } = createExecApprovalFixture();
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { twoPhase: true, host: "gateway", systemRunPlan: undefined, nodeId: undefined },
|
||||
});
|
||||
const acceptedId = respond.mock.calls.find((call) => call[1]?.status === "accepted")?.[1]?.id;
|
||||
expect(typeof acceptedId).toBe("string");
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: acceptedId as string,
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
await requestPromise;
|
||||
|
||||
const getRespond = vi.fn();
|
||||
await getExecApproval({ handlers, id: acceptedId as string, respond: getRespond });
|
||||
expect(getRespond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unknown or expired approval id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("broadcasts request + resolve", async () => {
|
||||
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||
|
||||
@@ -901,7 +1013,7 @@ describe("exec approval handlers", () => {
|
||||
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("rejects ambiguous short approval id prefixes", async () => {
|
||||
it("rejects ambiguous short approval id prefixes without leaking candidate ids", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
@@ -929,7 +1041,7 @@ describe("exec approval handlers", () => {
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("ambiguous approval id prefix"),
|
||||
message: "ambiguous approval id prefix; use the full id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1067,6 +1179,116 @@ describe("exec approval handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps approvals pending when iOS push delivery accepted the request", async () => {
|
||||
const iosPushDelivery = {
|
||||
handleRequested: vi.fn(async () => true),
|
||||
handleResolved: vi.fn(async () => {}),
|
||||
handleExpired: vi.fn(async () => {}),
|
||||
};
|
||||
const { manager, handlers, forwarder, respond, context } = createForwardingExecApprovalFixture({
|
||||
iosPushDelivery,
|
||||
});
|
||||
const expireSpy = vi.spyOn(manager, "expire");
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
twoPhase: true,
|
||||
timeoutMs: 60_000,
|
||||
id: "approval-ios-push",
|
||||
host: "gateway",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ status: "accepted", id: "approval-ios-push" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(iosPushDelivery.handleRequested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "approval-ios-push" }),
|
||||
);
|
||||
expect(expireSpy).not.toHaveBeenCalled();
|
||||
|
||||
manager.resolve("approval-ios-push", "allow-once");
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
it("sends iOS cleanup delivery on resolve", async () => {
|
||||
const iosPushDelivery = {
|
||||
handleRequested: vi.fn(async () => true),
|
||||
handleResolved: vi.fn(async () => {}),
|
||||
handleExpired: vi.fn(async () => {}),
|
||||
};
|
||||
const { handlers, respond, context } = createForwardingExecApprovalFixture({ iosPushDelivery });
|
||||
const resolveRespond = vi.fn();
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: { timeoutMs: 60_000, id: "approval-ios-cleanup", host: "gateway" },
|
||||
});
|
||||
await drainApprovalRequestTicks();
|
||||
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id: "approval-ios-cleanup",
|
||||
respond: resolveRespond,
|
||||
context,
|
||||
});
|
||||
await requestPromise;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(iosPushDelivery.handleResolved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "approval-ios-cleanup", decision: "allow-once" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends iOS cleanup delivery on expiration", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const iosPushDelivery = {
|
||||
handleRequested: vi.fn(async () => true),
|
||||
handleResolved: vi.fn(async () => {}),
|
||||
handleExpired: vi.fn(async () => {}),
|
||||
};
|
||||
const { handlers, respond, context } = createForwardingExecApprovalFixture({
|
||||
iosPushDelivery,
|
||||
});
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
twoPhase: true,
|
||||
timeoutMs: 250,
|
||||
id: "approval-ios-expire",
|
||||
host: "gateway",
|
||||
},
|
||||
});
|
||||
await drainApprovalRequestTicks();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await requestPromise;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(iosPushDelivery.handleExpired).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "approval-ios-expire" }),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps approvals pending when the originating chat can handle /approve directly", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -821,6 +821,33 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
wsBootstrap.close();
|
||||
});
|
||||
|
||||
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const replay = await connectReq(wsReplay, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(replay.ok).toBe(false);
|
||||
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsReplay.close();
|
||||
|
||||
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const reconnect = await connectReq(wsReconnect, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: issuedDeviceToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(reconnect.ok).toBe(true);
|
||||
wsReconnect.close();
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
@@ -839,6 +866,19 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedOperatorToken,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
type GatewayUpdateAvailableEventPayload,
|
||||
} from "./events.js";
|
||||
import { createExecApprovalIosPushDelivery } from "./exec-approval-ios-push.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { startMcpLoopbackServer } from "./mcp-http.js";
|
||||
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
|
||||
@@ -1203,8 +1204,10 @@ export async function startGatewayServer(
|
||||
|
||||
const execApprovalManager = new ExecApprovalManager();
|
||||
const execApprovalForwarder = createExecApprovalForwarder();
|
||||
const execApprovalIosPushDelivery = createExecApprovalIosPushDelivery({ log });
|
||||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
|
||||
forwarder: execApprovalForwarder,
|
||||
iosPushDelivery: execApprovalIosPushDelivery,
|
||||
});
|
||||
const pluginApprovalManager = new ExecApprovalManager<
|
||||
import("../infra/plugin-approvals.js").PluginApprovalRequestPayload
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { WebSocket } from "ws";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import {
|
||||
getBoundDeviceBootstrapProfile,
|
||||
getDeviceBootstrapTokenProfile,
|
||||
redeemDeviceBootstrapTokenProfile,
|
||||
revokeDeviceBootstrapToken,
|
||||
restoreDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "../../../infra/device-bootstrap.js";
|
||||
import {
|
||||
@@ -718,8 +719,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
}
|
||||
let bootstrapProfile: DeviceBootstrapProfile | null = null;
|
||||
let shouldConsumeBootstrapTokenAfterHello = false;
|
||||
const issuedBootstrapProfile =
|
||||
authMethod === "bootstrap-token" && bootstrapTokenCandidate
|
||||
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
|
||||
: null;
|
||||
let boundBootstrapProfile: DeviceBootstrapProfile | null = null;
|
||||
let handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
|
||||
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
@@ -826,7 +831,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
});
|
||||
};
|
||||
if (
|
||||
bootstrapProfile === null &&
|
||||
boundBootstrapProfile === null &&
|
||||
authMethod === "bootstrap-token" &&
|
||||
reason === "not-paired" &&
|
||||
role === "node" &&
|
||||
@@ -834,7 +839,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
!existingPairedDevice &&
|
||||
bootstrapTokenCandidate
|
||||
) {
|
||||
bootstrapProfile = await getBoundDeviceBootstrapProfile({
|
||||
boundBootstrapProfile = await getBoundDeviceBootstrapProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
@@ -847,17 +852,18 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
isWebchat,
|
||||
reason,
|
||||
});
|
||||
// QR bootstrap onboarding stays single-use, but only consume the bootstrap token
|
||||
// after the hello-ok path succeeds so reconnects can recover from pre-hello failures.
|
||||
// QR bootstrap onboarding stays single-use, but the first node bootstrap handshake
|
||||
// should seed bounded device tokens and only consume the bootstrap token once the
|
||||
// hello-ok path succeeds so reconnects can recover from pre-hello failures.
|
||||
const allowSilentBootstrapPairing =
|
||||
authMethod === "bootstrap-token" &&
|
||||
reason === "not-paired" &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!existingPairedDevice &&
|
||||
bootstrapProfile !== null;
|
||||
boundBootstrapProfile !== null;
|
||||
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
|
||||
? bootstrapProfile
|
||||
? boundBootstrapProfile
|
||||
: null;
|
||||
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
|
||||
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
|
||||
@@ -900,8 +906,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
callerScopes: scopes,
|
||||
});
|
||||
if (approved?.status === "approved") {
|
||||
if (allowSilentBootstrapPairing) {
|
||||
shouldConsumeBootstrapTokenAfterHello = true;
|
||||
if (bootstrapProfileForSilentApproval) {
|
||||
handoffBootstrapProfile = bootstrapProfileForSilentApproval;
|
||||
}
|
||||
logGateway.info(
|
||||
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||
@@ -1072,8 +1078,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
||||
});
|
||||
}
|
||||
if (device && bootstrapProfile !== null) {
|
||||
const bootstrapProfileForHello = bootstrapProfile as DeviceBootstrapProfile;
|
||||
if (device && handoffBootstrapProfile) {
|
||||
const bootstrapProfileForHello = handoffBootstrapProfile as DeviceBootstrapProfile;
|
||||
for (const bootstrapRole of bootstrapProfileForHello.roles) {
|
||||
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
|
||||
continue;
|
||||
@@ -1269,44 +1275,47 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
);
|
||||
}
|
||||
|
||||
let consumedBootstrapTokenRecord:
|
||||
| Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
|
||||
| undefined;
|
||||
if (shouldConsumeBootstrapTokenAfterHello && bootstrapTokenCandidate && device) {
|
||||
try {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
consumedBootstrapTokenRecord = revoked.record;
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after bootstrap handoff device=${device.id}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logGateway.warn(
|
||||
`bootstrap token consume failed after device-token handoff device=${device.id}: ${formatForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk });
|
||||
} catch (err) {
|
||||
if (consumedBootstrapTokenRecord) {
|
||||
try {
|
||||
await restoreDeviceBootstrapToken({
|
||||
record: consumedBootstrapTokenRecord,
|
||||
});
|
||||
} catch (restoreErr) {
|
||||
logGateway.warn(
|
||||
`bootstrap token restore failed after hello send error device=${device?.id ?? "unknown"}: ${formatForLog(restoreErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
setCloseCause("hello-send-failed", { error: formatForLog(err) });
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) {
|
||||
try {
|
||||
if (handoffBootstrapProfile) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after device-token handoff device=${device.id}`,
|
||||
);
|
||||
}
|
||||
} else if (issuedBootstrapProfile) {
|
||||
const redemption = await redeemDeviceBootstrapTokenProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (redemption.fullyRedeemed) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logGateway.warn(
|
||||
`bootstrap token post-connect bookkeeping failed device=${device.id}: ${formatForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
logWs("out", "hello-ok", {
|
||||
connId,
|
||||
methods: gatewayMethods.length,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendApnsAlert, sendApnsBackgroundWake } from "./push-apns.js";
|
||||
import {
|
||||
sendApnsAlert,
|
||||
sendApnsBackgroundWake,
|
||||
sendApnsExecApprovalAlert,
|
||||
sendApnsExecApprovalResolvedWake,
|
||||
} from "./push-apns.js";
|
||||
|
||||
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
||||
.privateKey.export({ format: "pem", type: "pkcs8" })
|
||||
@@ -153,6 +158,93 @@ describe("push APNs send semantics", () => {
|
||||
expect(result.transport).toBe("direct");
|
||||
});
|
||||
|
||||
it("sends exec approval alert pushes with generic modal-only metadata", async () => {
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-approval-alert",
|
||||
environment: "sandbox",
|
||||
sendResult: {
|
||||
status: 200,
|
||||
apnsId: "apns-approval-alert-id",
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendApnsExecApprovalAlert({
|
||||
registration,
|
||||
nodeId: "ios-node-approval-alert",
|
||||
approvalId: "approval-123",
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const sent = send.mock.calls[0]?.[0];
|
||||
expect(sent?.pushType).toBe("alert");
|
||||
expect(sent?.payload).toMatchObject({
|
||||
aps: {
|
||||
alert: {
|
||||
title: "Exec approval required",
|
||||
body: "Open OpenClaw to review this request.",
|
||||
},
|
||||
sound: "default",
|
||||
},
|
||||
openclaw: {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: "approval-123",
|
||||
},
|
||||
});
|
||||
expect(sent?.payload).not.toMatchObject({
|
||||
aps: {
|
||||
category: expect.anything(),
|
||||
},
|
||||
openclaw: {
|
||||
host: expect.anything(),
|
||||
nodeId: expect.anything(),
|
||||
agentId: expect.anything(),
|
||||
commandText: expect.anything(),
|
||||
allowedDecisions: expect.anything(),
|
||||
expiresAtMs: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.transport).toBe("direct");
|
||||
});
|
||||
|
||||
it("sends exec approval cleanup pushes as silent background notifications", async () => {
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-approval-cleanup",
|
||||
environment: "sandbox",
|
||||
sendResult: {
|
||||
status: 200,
|
||||
apnsId: "apns-approval-cleanup-id",
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendApnsExecApprovalResolvedWake({
|
||||
registration,
|
||||
nodeId: "ios-node-approval-cleanup",
|
||||
approvalId: "approval-123",
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const sent = send.mock.calls[0]?.[0];
|
||||
expect(sent?.pushType).toBe("background");
|
||||
expect(sent?.payload).toMatchObject({
|
||||
aps: {
|
||||
"content-available": 1,
|
||||
},
|
||||
openclaw: {
|
||||
kind: "exec.approval.resolved",
|
||||
approvalId: "approval-123",
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.transport).toBe("direct");
|
||||
});
|
||||
|
||||
it("parses direct send failures and clamps sub-second timeouts", async () => {
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-direct-fail",
|
||||
@@ -335,4 +427,57 @@ describe("push APNs send semantics", () => {
|
||||
transport: "relay",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends relay exec approval alerts with generic modal-only metadata", async () => {
|
||||
const { send, registration, relayConfig, gatewayIdentity } = createRelayApnsSendFixture({
|
||||
nodeId: "ios-node-relay-approval-alert",
|
||||
sendResult: {
|
||||
ok: true,
|
||||
status: 202,
|
||||
apnsId: "relay-approval-alert-id",
|
||||
environment: "production",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendApnsExecApprovalAlert({
|
||||
registration,
|
||||
nodeId: "ios-node-relay-approval-alert",
|
||||
approvalId: "approval-relay-1",
|
||||
relayConfig,
|
||||
relayGatewayIdentity: gatewayIdentity,
|
||||
relayRequestSender: send,
|
||||
});
|
||||
|
||||
const sent = send.mock.calls[0]?.[0];
|
||||
expect(sent?.payload).toMatchObject({
|
||||
aps: {
|
||||
alert: {
|
||||
title: "Exec approval required",
|
||||
body: "Open OpenClaw to review this request.",
|
||||
},
|
||||
},
|
||||
openclaw: {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: "approval-relay-1",
|
||||
},
|
||||
});
|
||||
expect(sent?.payload).not.toMatchObject({
|
||||
aps: {
|
||||
category: expect.anything(),
|
||||
},
|
||||
openclaw: {
|
||||
commandText: expect.anything(),
|
||||
host: expect.anything(),
|
||||
nodeId: expect.anything(),
|
||||
allowedDecisions: expect.anything(),
|
||||
expiresAtMs: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
status: 202,
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,8 @@ export type ApnsPushResult = {
|
||||
export type ApnsPushAlertResult = ApnsPushResult;
|
||||
export type ApnsPushWakeResult = ApnsPushResult;
|
||||
|
||||
const EXEC_APPROVAL_GENERIC_ALERT_BODY = "Open OpenClaw to review this request.";
|
||||
|
||||
type ApnsPushType = "alert" | "background";
|
||||
|
||||
type ApnsRequestParams = {
|
||||
@@ -894,6 +896,40 @@ function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExecApprovalAlertBody(): string {
|
||||
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
|
||||
}
|
||||
|
||||
function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: string }): object {
|
||||
return {
|
||||
aps: {
|
||||
alert: {
|
||||
title: "Exec approval required",
|
||||
body: resolveExecApprovalAlertBody(),
|
||||
},
|
||||
sound: "default",
|
||||
},
|
||||
openclaw: {
|
||||
kind: "exec.approval.requested",
|
||||
approvalId: params.approvalId,
|
||||
ts: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: string }): object {
|
||||
return {
|
||||
aps: {
|
||||
"content-available": 1,
|
||||
},
|
||||
openclaw: {
|
||||
kind: "exec.approval.resolved",
|
||||
approvalId: params.approvalId,
|
||||
ts: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ApnsAlertCommonParams = {
|
||||
nodeId: string;
|
||||
title: string;
|
||||
@@ -941,6 +977,52 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||
requestSender?: never;
|
||||
};
|
||||
|
||||
type ApnsExecApprovalAlertCommonParams = {
|
||||
nodeId: string;
|
||||
approvalId: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type DirectApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
|
||||
registration: DirectApnsRegistration;
|
||||
auth: ApnsAuthConfig;
|
||||
requestSender?: ApnsRequestSender;
|
||||
relayConfig?: never;
|
||||
relayRequestSender?: never;
|
||||
};
|
||||
|
||||
type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
|
||||
registration: RelayApnsRegistration;
|
||||
relayConfig: ApnsRelayConfig;
|
||||
relayRequestSender?: ApnsRelayRequestSender;
|
||||
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
||||
auth?: never;
|
||||
requestSender?: never;
|
||||
};
|
||||
|
||||
type ApnsExecApprovalResolvedCommonParams = {
|
||||
nodeId: string;
|
||||
approvalId: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type DirectApnsExecApprovalResolvedParams = ApnsExecApprovalResolvedCommonParams & {
|
||||
registration: DirectApnsRegistration;
|
||||
auth: ApnsAuthConfig;
|
||||
requestSender?: ApnsRequestSender;
|
||||
relayConfig?: never;
|
||||
relayRequestSender?: never;
|
||||
};
|
||||
|
||||
type RelayApnsExecApprovalResolvedParams = ApnsExecApprovalResolvedCommonParams & {
|
||||
registration: RelayApnsRegistration;
|
||||
relayConfig: ApnsRelayConfig;
|
||||
relayRequestSender?: ApnsRelayRequestSender;
|
||||
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
||||
auth?: never;
|
||||
requestSender?: never;
|
||||
};
|
||||
|
||||
export async function sendApnsAlert(
|
||||
params: DirectApnsAlertParams | RelayApnsAlertParams,
|
||||
): Promise<ApnsPushAlertResult> {
|
||||
@@ -1006,4 +1088,68 @@ export async function sendApnsBackgroundWake(
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendApnsExecApprovalAlert(
|
||||
params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams,
|
||||
): Promise<ApnsPushAlertResult> {
|
||||
const payload = createExecApprovalAlertPayload({
|
||||
nodeId: params.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
const relayParams = params as RelayApnsExecApprovalAlertParams;
|
||||
return await sendRelayApnsPush({
|
||||
relayConfig: relayParams.relayConfig,
|
||||
registration: relayParams.registration,
|
||||
payload,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
gatewayIdentity: relayParams.relayGatewayIdentity,
|
||||
requestSender: relayParams.relayRequestSender,
|
||||
});
|
||||
}
|
||||
const directParams = params as DirectApnsExecApprovalAlertParams;
|
||||
return await sendDirectApnsPush({
|
||||
auth: directParams.auth,
|
||||
registration: directParams.registration,
|
||||
payload,
|
||||
timeoutMs: directParams.timeoutMs,
|
||||
requestSender: directParams.requestSender,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendApnsExecApprovalResolvedWake(
|
||||
params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams,
|
||||
): Promise<ApnsPushWakeResult> {
|
||||
const payload = createExecApprovalResolvedPayload({
|
||||
nodeId: params.nodeId,
|
||||
approvalId: params.approvalId,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
const relayParams = params as RelayApnsExecApprovalResolvedParams;
|
||||
return await sendRelayApnsPush({
|
||||
relayConfig: relayParams.relayConfig,
|
||||
registration: relayParams.registration,
|
||||
payload,
|
||||
pushType: "background",
|
||||
priority: "5",
|
||||
gatewayIdentity: relayParams.relayGatewayIdentity,
|
||||
requestSender: relayParams.relayRequestSender,
|
||||
});
|
||||
}
|
||||
const directParams = params as DirectApnsExecApprovalResolvedParams;
|
||||
return await sendDirectApnsPush({
|
||||
auth: directParams.auth,
|
||||
registration: directParams.registration,
|
||||
payload,
|
||||
timeoutMs: directParams.timeoutMs,
|
||||
requestSender: directParams.requestSender,
|
||||
pushType: "background",
|
||||
priority: "5",
|
||||
});
|
||||
}
|
||||
|
||||
export { type ApnsRelayConfig, type ApnsRelayConfigResolution, resolveApnsRelayConfigFromEnv };
|
||||
|
||||
Reference in New Issue
Block a user