Compare commits

...

5 Commits

Author SHA1 Message Date
Nimrod Gutman
0c74710a15 fix(ios): simplify exec approval modal flow 2026-04-03 12:15:52 +03:00
Nimrod Gutman
148206bc38 fix(auth): tighten exec approval bootstrap security 2026-04-03 12:15:52 +03:00
Nimrod Gutman
8399e3abb7 fix(ios): harden exec approval onboarding flow 2026-04-03 12:15:52 +03:00
Nimrod Gutman
5fb6ac71c1 fix(ios): address review feedback 2026-04-03 12:15:52 +03:00
Nimrod Gutman
3b7ddc2f5e feat(ios): add exec approval push flow 2026-04-03 12:15:52 +03:00
34 changed files with 2848 additions and 175 deletions

View 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())
}
}

View File

@@ -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,9 @@ 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 queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
@@ -1816,7 +1843,7 @@ private extension NodeAppModel {
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
}
static func shouldStartOperatorGatewayLoop(
nonisolated static func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
@@ -1837,7 +1864,7 @@ private extension NodeAppModel {
return hasStoredOperatorToken
}
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
guard let config else { return nil }
let trimmedBootstrapToken = config.bootstrapToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -1878,6 +1905,36 @@ private extension NodeAppModel {
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
}
private func handleSuccessfulBootstrapGatewayOnboarding(
url: URL,
stableID: String,
token: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?) async
{
self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: nil,
password: password,
stableID: stableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: stableID,
token: token,
bootstrapToken: nil,
password: password,
nodeOptions: nodeOptions,
sessionBox: sessionBox)
}
// QR bootstrap onboarding should surface the system notification permission
// prompt immediately so visible APNs alerts work without a second manual step.
_ = await self.requestNotificationAuthorizationIfNeeded()
}
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return }
@@ -2049,13 +2106,14 @@ private extension NodeAppModel {
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
let connectedOptions = currentOptions
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
try await self.nodeGateway.connect(
url: url,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: currentOptions,
connectOptions: connectedOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
@@ -2071,24 +2129,13 @@ private extension NodeAppModel {
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty == false
if usedBootstrapToken {
await MainActor.run {
self.clearPersistedGatewayBootstrapTokenIfNeeded()
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
stableID: stableID)
{
self.startOperatorGatewayLoop(
url: url,
stableID: stableID,
token: reconnectAuth.token,
bootstrapToken: nil,
password: reconnectAuth.password,
nodeOptions: currentOptions,
sessionBox: sessionBox)
}
}
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: url,
stableID: stableID,
token: reconnectAuth.token,
password: reconnectAuth.password,
nodeOptions: connectedOptions,
sessionBox: sessionBox)
}
let relayData = await MainActor.run {
(
@@ -2249,7 +2296,7 @@ private extension NodeAppModel {
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
GatewayConnectOptions(
role: "operator",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
scopes: ["operator.read", "operator.write", "operator.approvals", "operator.talk.secrets"],
caps: [],
commands: [],
permissions: [:],
@@ -2547,6 +2594,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) "
@@ -2719,6 +2779,203 @@ extension NodeAppModel {
return "unknown"
}
private struct ExecApprovalGetRequest: Encodable {
var id: String
}
private struct ExecApprovalResolveRequest: Encodable {
var id: String
var 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.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId)
self.pendingExecApprovalPromptResolving = false
switch fetchedPrompt {
case let .loaded(fetchedPrompt):
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
case .stale:
await ExecApprovalNotificationBridge.removeNotifications(
forApprovalID: approvalId,
notificationCenter: self.notificationCenter)
self.dismissPendingExecApprovalPrompt()
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()
}
private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
let message = gatewayError.message.lowercased()
return gatewayError.code == "INVALID_REQUEST"
&& (message.contains("unknown or expired approval id") || message.contains("approval_not_found"))
}
private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
let message = gatewayError.message.lowercased()
return gatewayError.code == "INVALID_REQUEST"
&& message.contains("allow-always is unavailable")
}
private struct SilentPushWakeAttemptResult {
var applied: Bool
var reason: String
@@ -2730,14 +2987,51 @@ 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 ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
if await self.isOperatorConnected() {
return true
}
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
}
private func reconnectGatewaySessionsForSilentPushIfNeeded(
wakeId: String
) async -> SilentPushWakeAttemptResult {
@@ -3137,11 +3431,50 @@ extension NodeAppModel {
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
func _test_makeOperatorConnectOptions(
clientId: String,
displayName: String?
) -> GatewayConnectOptions {
self.makeOperatorConnectOptions(clientId: clientId, displayName: displayName)
}
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
self.presentFetchedExecApprovalPrompt(prompt)
}
func _test_dismissPendingExecApprovalPrompt() {
self.dismissPendingExecApprovalPrompt()
}
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
self.pendingExecApprovalPrompt
}
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()
}
static func _test_shouldStartOperatorGatewayLoop(
nonisolated static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
@@ -3154,6 +3487,29 @@ extension NodeAppModel {
hasStoredOperatorToken: hasStoredOperatorToken)
}
nonisolated static func _test_clearingBootstrapToken(
in config: GatewayConnectConfig?
) -> GatewayConnectConfig? {
self.clearingBootstrapToken(in: config)
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,
stableID: "test-gateway",
token: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil),
sessionBox: nil)
}
}
#endif
// swiftlint:enable type_body_length file_length

View File

@@ -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()
}
}

View 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
}
}

View File

@@ -107,6 +107,7 @@ struct RootCanvas: View {
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:

View File

@@ -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)
})
}
}
}
}

View 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"]])
}
}

View File

@@ -70,6 +70,19 @@ import UIKit
}
}
@Test @MainActor func operatorConnectOptionsRequestApprovalScope() {
let appModel = NodeAppModel()
let options = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS")
#expect(options.role == "operator")
#expect(options.scopes.contains("operator.read"))
#expect(options.scopes.contains("operator.write"))
#expect(options.scopes.contains("operator.approvals"))
#expect(options.scopes.contains("operator.talk.secrets"))
}
@Test @MainActor func loadLastConnectionReadsSavedValues() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
defer {

View File

@@ -2,6 +2,7 @@ import OpenClawKit
import Foundation
import Testing
import UIKit
import UserNotifications
@testable import OpenClaw
private func makeAgentDeepLinkURL(
@@ -68,6 +69,36 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
}
}
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
var status: NotificationAuthorizationStatus = .notDetermined
var requestAuthorizationResult = false
var requestAuthorizationCalls = 0
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.status
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
self.requestAuthorizationCalls += 1
if self.requestAuthorizationResult {
self.status = .authorized
} else {
self.status = .denied
}
return self.requestAuthorizationResult
}
func add(_: UNNotificationRequest) async throws {}
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
func deliveredNotifications() async -> [NotificationSnapshot] {
[]
}
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@Test @MainActor func decodeParamsFailsWithoutJSON() {
#expect(throws: Error.self) {
@@ -96,6 +127,44 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
#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 func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
@@ -127,6 +196,15 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
)
}
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
let appModel = NodeAppModel(notificationCenter: center)
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
#expect(center.requestAuthorizationCalls == 1)
}
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
let config = GatewayConnectConfig(
url: URL(string: "wss://gateway.example")!,
@@ -145,7 +223,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
clientMode: "node",
clientDisplayName: nil))
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
let cleared = NodeAppModel._test_clearingBootstrapToken(in: config)
#expect(cleared?.bootstrapToken == nil)
#expect(cleared?.url == config.url)
#expect(cleared?.stableID == config.stableID)

View File

@@ -542,6 +542,52 @@ public actor GatewayChannelActor {
authSource: authSource)
}
private func shouldPersistBootstrapHandoffTokens() -> Bool {
guard self.lastAuthSource == .bootstrapToken else { return false }
let scheme = self.url.scheme?.lowercased()
if scheme == "wss" {
return true
}
if let host = self.url.host, LoopbackHost.isLoopback(host) {
return true
}
return false
}
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines)
switch normalizedRole {
case "node":
return []
case "operator":
let allowedOperatorScopes: Set<String> = [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]
return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted()
default:
return nil
}
}
private func persistBootstrapHandoffToken(
deviceId: String,
role: String,
token: String,
scopes: [String]
) {
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
@@ -572,18 +618,34 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
if let identity {
_ = DeviceAuthStore.storeToken(
if let auth = ok.auth, let identity, self.shouldPersistBootstrapHandoffTokens() {
if let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistBootstrapHandoffToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
if let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] {
for entry in tokenEntries {
guard let rawEntry = entry.value as? [String: ProtoAnyCodable],
let deviceToken = rawEntry["deviceToken"]?.value as? String,
let authRole = rawEntry["role"]?.value as? String
else {
continue
}
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistBootstrapHandoffToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
}
}
self.lastTick = Date()
self.tickTask?.cancel()

View File

@@ -13,6 +13,7 @@ private extension NSLock {
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
@@ -20,6 +21,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
init(helloAuth: [String: Any]? = nil) {
self.helloAuth = helloAuth
}
var state: URLSessionTask.State {
get { self.lock.withLock { self._state } }
set { self.lock.withLock { self._state = newValue } }
@@ -79,11 +84,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
for _ in 0..<50 {
let id = self.lock.withLock { self.connectRequestId }
if let id {
return .data(Self.connectOkData(id: id))
return .data(Self.connectOkData(id: id, auth: self.helloAuth))
}
try await Task.sleep(nanoseconds: 1_000_000)
}
return .data(Self.connectOkData(id: "connect"))
return .data(Self.connectOkData(id: "connect", auth: self.helloAuth))
}
func receive(
@@ -110,8 +115,8 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func connectOkData(id: String) -> Data {
let payload: [String: Any] = [
private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data {
var payload: [String: Any] = [
"type": "hello-ok",
"protocol": 2,
"server": [
@@ -137,6 +142,9 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"tickIntervalMs": 30_000,
],
]
if let auth {
payload["auth"] = auth
}
let frame: [String: Any] = [
"type": "res",
"id": id,
@@ -149,9 +157,14 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private var tasks: [FakeGatewayWebSocketTask] = []
private var makeCount = 0
init(helloAuth: [String: Any]? = nil) {
self.helloAuth = helloAuth
}
func snapshotMakeCount() -> Int {
self.lock.withLock { self.makeCount }
}
@@ -164,7 +177,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
_ = url
return self.lock.withLock {
self.makeCount += 1
let task = FakeGatewayWebSocketTask()
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth)
self.tasks.append(task)
return WebSocketTaskBox(task: task)
}
@@ -234,6 +247,145 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "node-device-token",
"role": "node",
"scopes": [],
"issuedAtMs": 1000,
"deviceTokens": [
[
"deviceToken": "node-device-token",
"role": "node",
"scopes": ["operator.admin"],
"issuedAtMs": 1000,
],
[
"deviceToken": "operator-device-token",
"role": "operator",
"scopes": [
"operator.admin",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
"issuedAtMs": 1001,
],
],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator"))
#expect(nodeEntry.token == "node-device-token")
#expect(nodeEntry.scopes == [])
#expect(operatorEntry.token == "operator-device-token")
#expect(operatorEntry.scopes.contains("operator.approvals"))
#expect(!operatorEntry.scopes.contains("operator.admin"))
await gateway.disconnect()
}
@Test
func nonBootstrapHelloDoesNotOverwriteStoredDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "server-node-token",
"role": "node",
"scopes": [],
"deviceTokens": [
[
"deviceToken": "server-operator-token",
"role": "operator",
"scopes": ["operator.admin"],
],
],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
try await gateway.connect(
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
await gateway.disconnect()
}
@Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(

View File

@@ -0,0 +1,227 @@
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();
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);
await Promise.resolve();
expect(loadApnsRegistrationMock).toHaveBeenCalledWith("ios-device-1");
expect(sendApnsExecApprovalAlertMock).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,
});
await Promise.resolve();
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);
});
});

View File

@@ -0,0 +1,332 @@
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;
};
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 approvalTargetsById = new Map<string, string[]>();
return {
async handleRequested(request: ExecApprovalRequest): Promise<boolean> {
const plan = await resolveDeliveryPlan({
requireApprovalScope: true,
log: params.log,
});
if (plan.targets.length === 0) {
approvalTargetsById.delete(request.id);
return false;
}
approvalTargetsById.set(
request.id,
plan.targets.map((target) => target.nodeId),
);
void sendRequestedPushes({ request, plan, log: params.log })
.then(({ attempted, delivered }) => {
if (attempted > 0 && delivered === 0) {
params.log.warn?.(
`exec approvals: iOS request push reached no devices approvalId=${request.id} attempted=${attempted}`,
);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
params.log.error?.(`exec approvals: iOS request push failed: ${message}`);
});
return true;
},
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const explicitNodeIds = approvalTargetsById.get(resolved.id);
approvalTargetsById.delete(resolved.id);
if (!explicitNodeIds?.length) {
params.log.debug?.(
`exec approvals: iOS cleanup push skipped approvalId=${resolved.id} reason=missing-targets`,
);
return;
}
const plan = await resolveDeliveryPlan({
requireApprovalScope: false,
explicitNodeIds,
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 explicitNodeIds = approvalTargetsById.get(request.id);
approvalTargetsById.delete(request.id);
if (!explicitNodeIds?.length) {
params.log.debug?.(
`exec approvals: iOS cleanup push skipped approvalId=${request.id} reason=missing-targets`,
);
return;
}
const plan = await resolveDeliveryPlan({
requireApprovalScope: false,
explicitNodeIds,
log: params.log,
});
if (plan.targets.length === 0) {
return;
}
await sendResolvedPushes({
approvalId: request.id,
plan,
log: params.log,
});
},
};
}

View File

@@ -73,12 +73,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",

View File

@@ -33,6 +33,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",

View File

@@ -120,6 +120,8 @@ import {
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
type ExecApprovalGetParams,
ExecApprovalGetParamsSchema,
type ExecApprovalRequestParams,
ExecApprovalRequestParamsSchema,
type ExecApprovalResolveParams,
@@ -435,6 +437,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,
);

View File

@@ -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),

View File

@@ -96,6 +96,19 @@ export const HelloOkSchema = Type.Object(
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
deviceTokens: Type.Optional(
Type.Array(
Type.Object(
{
deviceToken: NonEmptyString,
role: NonEmptyString,
scopes: Type.Array(NonEmptyString),
issuedAtMs: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
),
),
},
{ additionalProperties: false },
),

View File

@@ -94,6 +94,7 @@ import {
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
ExecApprovalGetParamsSchema,
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
} from "./exec-approvals.js";
@@ -304,6 +305,7 @@ export const ProtocolSchemas = {
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ExecApprovalGetParams: ExecApprovalGetParamsSchema,
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,

View File

@@ -126,6 +126,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">;

View File

@@ -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",

View File

@@ -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,86 @@ const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
} 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 +262,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 +277,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 +322,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 +390,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(
@@ -354,22 +431,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);
},
};

View File

@@ -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 {

View File

@@ -711,8 +711,11 @@ export function registerControlUiAndPairingSuite(): void {
});
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
@@ -746,54 +749,81 @@ export function registerControlUiAndPairingSuite(): void {
deviceToken?: string;
role?: string;
scopes?: string[];
deviceTokens?: Array<{
deviceToken?: string;
role?: string;
scopes?: string[];
}>;
};
}
| undefined;
expect(initialPayload?.type).toBe("hello-ok");
const issuedDeviceToken = initialPayload?.auth?.deviceToken;
const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
)?.deviceToken;
expect(issuedDeviceToken).toBeDefined();
expect(issuedOperatorToken).toBeDefined();
expect(initialPayload?.auth?.role).toBe("node");
expect(initialPayload?.auth?.scopes ?? []).toEqual([]);
expect(
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
).toEqual(
expect.arrayContaining([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]),
);
const afterBootstrap = await listDevicePairing();
expect(
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]);
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.approvedScopes ?? []).toEqual(
expect.arrayContaining([
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]),
);
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
if (!issuedDeviceToken) {
throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding");
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding");
}
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,
await new Promise<void>((resolve) => {
if (wsBootstrap.readyState === WebSocket.CLOSED) {
resolve();
return;
}
wsBootstrap.once("close", () => resolve());
wsBootstrap.close();
});
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,
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
role: "node",
scopes: [],
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedDeviceToken,
role: "node",
scopes: [],
}),
).resolves.toEqual({ ok: true });
} finally {
await server.close();
restoreGatewayToken(prevToken);

View File

@@ -91,6 +91,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 { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
import { NodeRegistry } from "./node-registry.js";
@@ -1187,8 +1188,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

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import {
getBoundDeviceBootstrapProfile,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
@@ -11,6 +12,7 @@ import {
normalizeDevicePublicKeyBase64Url,
} from "../../../infra/device-identity.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
@@ -31,6 +33,7 @@ import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@@ -697,6 +700,8 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
let bootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
@@ -784,6 +789,21 @@ export function attachGatewayWsMessageHandler(params: {
allowedScopes: pairedScopes,
});
};
if (
bootstrapProfile === null &&
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
bootstrapTokenCandidate
) {
bootstrapProfile = await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
});
}
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
isLocalClient,
hasBrowserOriginHeader,
@@ -791,19 +811,27 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// QR bootstrap onboarding is node-only and single-use. When a fresh device presents
// a valid bootstrap token for the baseline node profile, complete pairing in the same
// handshake so iOS does not get stuck retrying with an already-consumed bootstrap token.
// QR bootstrap onboarding stays single-use, but the first node bootstrap handshake
// should seed the full QR baseline so the app can switch over to stored node/operator
// device tokens without asking the user for shared auth.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice;
!existingPairedDevice &&
bootstrapProfile !== null;
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
? bootstrapProfile
: null;
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
: undefined;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}),
silent:
reason === "scope-upgrade"
? false
@@ -828,9 +856,14 @@ export function attachGatewayWsMessageHandler(params: {
return replacementPending?.requestId;
};
if (pairing.request.silent === true) {
approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
approved = bootstrapProfileForSilentApproval
? await approveBootstrapDevicePairing(
pairing.request.requestId,
bootstrapProfileForSilentApproval,
)
: await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing && bootstrapTokenCandidate) {
const revoked = await revokeDeviceBootstrapToken({
@@ -991,6 +1024,48 @@ export function attachGatewayWsMessageHandler(params: {
const deviceToken = device
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
const bootstrapDeviceTokens: Array<{
deviceToken: string;
role: string;
scopes: string[];
issuedAtMs: number;
}> = [];
if (deviceToken) {
bootstrapDeviceTokens.push({
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
});
}
const bootstrapProfileForHello: DeviceBootstrapProfile | null = device
? bootstrapProfile
: null;
if (device && bootstrapProfileForHello !== null) {
const bootstrapProfileForHelloResolved =
bootstrapProfileForHello as DeviceBootstrapProfile;
for (const bootstrapRole of bootstrapProfileForHelloResolved.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
continue;
}
const bootstrapRoleScopes =
bootstrapRole === "operator" ? bootstrapProfileForHelloResolved.scopes : [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,
scopes: bootstrapRoleScopes,
});
if (!extraToken) {
continue;
}
bootstrapDeviceTokens.push({
deviceToken: extraToken.token,
role: extraToken.role,
scopes: extraToken.scopes,
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs,
});
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({
@@ -1082,6 +1157,9 @@ export function attachGatewayWsMessageHandler(params: {
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens }
: {}),
}
: undefined,
policy: {

View File

@@ -5,10 +5,12 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
clearDeviceBootstrapTokens,
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
getBoundDeviceBootstrapProfile,
issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } from "./device-identity.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");
@@ -65,7 +67,7 @@ describe("device bootstrap tokens", () => {
issuedAtMs: Date.now(),
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
},
});
});
@@ -144,7 +146,12 @@ describe("device bootstrap tokens", () => {
issuedAtMs,
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
},
},
},
@@ -288,6 +295,37 @@ describe("device bootstrap tokens", () => {
expect(parsed[issued.token]?.token).toBe(issued.token);
});
it("accepts equivalent public key encodings after binding the bootstrap token", async () => {
const baseDir = await createTempDir();
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json"));
const issued = await issueDeviceBootstrapToken({ baseDir });
const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
await expect(
verifyBootstrapToken(baseDir, issued.token, {
deviceId: identity.deviceId,
publicKey: identity.publicKeyPem,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyBootstrapToken(baseDir, issued.token, {
deviceId: identity.deviceId,
publicKey: rawPublicKey,
}),
).resolves.toEqual({ ok: true });
await expect(
getBoundDeviceBootstrapProfile({
token: issued.token,
deviceId: identity.deviceId,
publicKey: rawPublicKey,
baseDir,
}),
).resolves.toEqual({
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
});
});
it("rejects a second device identity after the first verification binds the token", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });

View File

@@ -6,6 +6,7 @@ import {
type DeviceBootstrapProfileInput,
} from "../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { normalizeDevicePublicKeyBase64Url } from "./device-identity.js";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
@@ -75,6 +76,17 @@ function bootstrapProfileAllowsRequest(params: {
);
}
function normalizeBootstrapPublicKey(publicKey: string): string {
const trimmed = publicKey.trim();
if (!trimmed) {
return "";
}
if (trimmed.includes("BEGIN") || /[+/=]/.test(trimmed)) {
return normalizeDevicePublicKeyBase64Url(trimmed) ?? trimmed;
}
return trimmed;
}
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
@@ -192,7 +204,7 @@ export async function verifyDeviceBootstrapToken(params: {
const [tokenKey, record] = found;
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
const role = params.role.trim();
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
@@ -212,7 +224,10 @@ export async function verifyDeviceBootstrapToken(params: {
}
const boundDeviceId = record.deviceId?.trim();
const boundPublicKey = record.publicKey?.trim();
const boundPublicKey =
typeof record.publicKey === "string"
? normalizeBootstrapPublicKey(record.publicKey)
: undefined;
if (boundDeviceId || boundPublicKey) {
if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
return { ok: false, reason: "bootstrap_token_invalid" };
@@ -239,3 +254,38 @@ export async function verifyDeviceBootstrapToken(params: {
return { ok: true };
});
}
export async function getBoundDeviceBootstrapProfile(params: {
token: string;
deviceId: string;
publicKey: string;
baseDir?: string;
}): Promise<DeviceBootstrapProfile | null> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const providedToken = params.token.trim();
if (!providedToken) {
return null;
}
const found = Object.entries(state).find(([, candidate]) =>
verifyPairingToken(providedToken, candidate.token),
);
if (!found) {
return null;
}
const [, record] = found;
const deviceId = params.deviceId.trim();
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
if (!deviceId || !publicKey) {
return null;
}
const recordPublicKey =
typeof record.publicKey === "string"
? normalizeBootstrapPublicKey(record.publicKey)
: undefined;
if (record.deviceId?.trim() !== deviceId || recordPublicKey !== publicKey) {
return null;
}
return resolvePersistedBootstrapProfile(record);
});
}

View File

@@ -2,8 +2,10 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
clearDevicePairing,
ensureDeviceToken,
@@ -96,23 +98,27 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) {
async function mutatePairedDevice(
baseDir: string,
deviceId: string,
mutate: (device: PairedDevice) => void,
) {
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
string,
PairedDevice
>;
const device = pairedByDeviceId["device-1"];
const device = pairedByDeviceId[deviceId];
expect(device).toBeDefined();
if (!device) {
throw new Error("expected paired operator device");
throw new Error(`expected paired device ${deviceId}`);
}
mutate(device);
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
}
async function clearPairedOperatorApprovalBaseline(baseDir: string) {
await mutatePairedOperatorDevice(baseDir, (device) => {
await mutatePairedDevice(baseDir, "device-1", (device) => {
delete device.approvedScopes;
delete device.scopes;
});
@@ -431,6 +437,68 @@ describe("device pairing tokens", () => {
).resolves.toEqual({ ok: true });
});
test("normalizes legacy node token scopes back to [] on re-approval", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedNodeDevice(baseDir);
await mutatePairedDevice(baseDir, "node-1", (device) => {
const nodeToken = device.tokens?.node;
expect(nodeToken).toBeDefined();
if (!nodeToken) {
throw new Error("expected paired node token");
}
nodeToken.scopes = ["operator.read"];
});
const repair = await requestDevicePairing(
{
deviceId: "node-1",
publicKey: "public-key-node-1",
role: "node",
},
baseDir,
);
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
const paired = await getPairedDevice("node-1", baseDir);
expect(paired?.scopes).toEqual([]);
expect(paired?.approvedScopes).toEqual([]);
expect(paired?.tokens?.node?.scopes).toEqual([]);
});
test("bootstrap pairing seeds node and operator device tokens explicitly", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-1",
publicKey: "bootstrap-public-key-1",
role: "node",
roles: ["node", "operator"],
scopes: [],
silent: true,
},
baseDir,
);
await expect(
approveBootstrapDevicePairing(
request.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
baseDir,
),
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
const paired = await getPairedDevice("bootstrap-device-1", baseDir);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.approvedScopes).toEqual(
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
);
expect(paired?.tokens?.node?.scopes).toEqual([]);
expect(paired?.tokens?.operator?.scopes).toEqual(
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
);
});
test("verifies token and rejects mismatches", async () => {
const { baseDir, token } = await setupOperatorToken(["operator.read"]);

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js";
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
import {
createAsyncLock,
@@ -92,6 +93,7 @@ type DevicePairingStateFile = {
};
const PENDING_TTL_MS = 5 * 60 * 1000;
const OPERATOR_ROLE = "operator";
const OPERATOR_SCOPE_PREFIX = "operator.";
const withLock = createAsyncLock();
@@ -473,12 +475,14 @@ export async function approveDevicePairing(
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
options: { callerScopes?: readonly string[] },
options: { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] },
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
optionsOrBaseDir?:
| { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] }
| string,
maybeBaseDir?: string,
): Promise<ApproveDevicePairingResult> {
const options =
@@ -492,11 +496,15 @@ export async function approveDevicePairing(
if (!pending) {
return null;
}
const approvedScopesOverride = normalizeDeviceAuthScopes(
options?.approvedScopesOverride ? [...options.approvedScopesOverride] : undefined,
);
const approvalRole = resolvePendingApprovalRole(pending);
if (approvalRole) {
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
);
const requestedOperatorScopes = normalizeDeviceAuthScopes([
...(pending.scopes ?? []),
...approvedScopesOverride,
]).filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX));
if (!options?.callerScopes) {
return {
status: "forbidden",
@@ -518,21 +526,26 @@ export async function approveDevicePairing(
const approvedScopes = mergeScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
approvedScopesOverride,
);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
const roleForToken = normalizeRole(pending.role);
if (roleForToken) {
const existingToken = tokens[roleForToken];
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
// Only operator device tokens carry scopes. Non-operator tokens should normalize
// back to [] here even if older persisted state still carries stray scopes.
const nextScopes =
requestedScopes.length > 0
? requestedScopes
: normalizeDeviceAuthScopes(
existingToken?.scopes ??
approvedScopes ??
existing?.approvedScopes ??
existing?.scopes,
);
: roleForToken === OPERATOR_ROLE
? normalizeDeviceAuthScopes(
existingToken?.scopes ??
approvedScopes ??
existing?.approvedScopes ??
existing?.scopes,
)
: [];
const now = Date.now();
tokens[roleForToken] = {
token: newToken(),
@@ -568,6 +581,90 @@ export async function approveDevicePairing(
});
}
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,
baseDir?: string,
): Promise<ApproveDevicePairingResult> {
// QR bootstrap handoff is an explicit trust path: it can seed the bounded
// node/operator baseline from the verified bootstrap profile without routing
// operator scope approval through the generic interactive approval checker.
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
const approvedScopes = normalizeDeviceAuthScopes([...bootstrapProfile.scopes]);
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
}
const requestedRoles = resolveRequestedRoles(pending);
const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role));
if (missingRole) {
return { status: "forbidden", missingScope: missingRole };
}
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
);
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
requestedScopes: requestedOperatorScopes,
allowedScopes: approvedScopes,
});
if (missingScope) {
return { status: "forbidden", missingScope };
}
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(
existing?.roles,
existing?.role,
pending.roles,
pending.role,
approvedRoles,
);
const nextApprovedScopes = mergeScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
approvedScopes,
);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of approvedRoles) {
const existingToken = tokens[roleForToken];
const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : [];
tokens[roleForToken] = buildDeviceAuthToken({
role: roleForToken,
scopes: tokenScopes,
existing: existingToken,
now,
...(existingToken ? { rotatedAtMs: now } : {}),
});
}
const device: PairedDevice = {
deviceId: pending.deviceId,
publicKey: pending.publicKey,
displayName: pending.displayName,
platform: pending.platform,
deviceFamily: pending.deviceFamily,
clientId: pending.clientId,
clientMode: pending.clientMode,
role: pending.role,
roles,
scopes: nextApprovedScopes,
approvedScopes: nextApprovedScopes,
remoteIp: pending.remoteIp,
tokens,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
};
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, baseDir);
return { status: "approved", requestId, device };
});
}
export async function rejectDevicePairing(
requestId: string,
baseDir?: string,

View File

@@ -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",
});
});
});

View File

@@ -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 };

View File

@@ -75,7 +75,12 @@ describe("pairing setup code", () => {
expect.objectContaining({
profile: {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
},
}),
);

View File

@@ -12,7 +12,7 @@ export type DeviceBootstrapProfileInput = {
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
};
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {