feat(watchos): connect directly to Gateway as a node (#102893)

* feat(watchos): add direct gateway node

* docs: refresh watch node docs map

* chore: leave release notes to release workflow

* chore(ios): refresh native localization inventory

* fix(watchos): keep direct node policy bounded
This commit is contained in:
Peter Steinberger 2026-07-09 15:53:02 +01:00 committed by GitHub
parent b80062be3a
commit 54f45a950b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 3938 additions and 415 deletions

File diff suppressed because it is too large Load diff

View file

@ -63,6 +63,8 @@ struct SettingsProTab: View {
@State var showResetOnboardingAlert = false
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@State var watchDirectSetupStatusText: String?
@State var isSendingWatchDirectSetup = false
@State var locationPermissionSummary = LocationPermissionSummary(
desiredMode: .off,
locationServicesEnabled: true,

View file

@ -806,6 +806,7 @@ extension SettingsProTab {
func title(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Gateway"
case .appleWatch: "Apple Watch"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
@ -818,6 +819,21 @@ extension SettingsProTab {
}
}
func sendDirectWatchSetup() async {
guard !self.isSendingWatchDirectSetup else { return }
self.isSendingWatchDirectSetup = true
self.watchDirectSetupStatusText = "Preparing one-time setup…"
defer { self.isSendingWatchDirectSetup = false }
do {
let result = try await self.appModel.sendDirectWatchSetup()
self.watchDirectSetupStatusText = result.deliveredImmediately
? "Setup sent. Open OpenClaw on the watch to connect."
: "Setup queued for the watch. Open OpenClaw before the code expires."
} catch {
self.watchDirectSetupStatusText = error.localizedDescription
}
}
var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },

View file

@ -173,6 +173,11 @@ extension SettingsProTab {
iconColor: .indigo,
title: "Privacy",
route: .privacy)
self.settingsListRow(
icon: "applewatch",
iconColor: .green,
title: "Apple Watch",
route: .appleWatch)
self.settingsListRow(
icon: "info.circle.fill",
iconColor: .gray,
@ -224,6 +229,8 @@ extension SettingsProTab {
switch route {
case .gateway:
self.gatewayDestination
case .appleWatch:
self.appleWatchDestination
case .approvals:
self.approvalsDestination
case .permissions:
@ -247,6 +254,10 @@ extension SettingsProTab {
.font(OpenClawType.body)
.navigationTitle(title(for: route))
.navigationBarTitleDisplayMode(.inline)
.task(id: route) {
guard route == .appleWatch else { return }
await self.appModel.refreshWatchMessagingStatus()
}
.toolbar {
if let headerLeadingAction {
ToolbarItem(placement: .topBarLeading) {
@ -355,6 +366,50 @@ extension SettingsProTab {
}
}
var appleWatchDestination: some View {
Group {
let watchStatus = self.appModel.watchMessagingStatus
self.detailStatusCard(
icon: "applewatch",
title: "Apple Watch",
detail: watchStatus.appInstalled
? "Relay remains available; direct mode adds an independent Gateway node."
: "Install the OpenClaw watch app before enabling direct mode.",
value: watchStatus.reachable ? "Reachable" : (watchStatus.appInstalled ? "Installed" : "Unavailable"),
color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn)
Section {
Button {
Task { await self.sendDirectWatchSetup() }
} label: {
Label("Enable Direct Gateway Connection", systemImage: "point.3.connected.trianglepath.dotted")
.font(OpenClawType.body)
}
.disabled(
self.isSendingWatchDirectSetup
|| !self.appModel.isOperatorGatewayConnected
|| !self.appModel.hasOperatorAdminScope
|| !watchStatus.appInstalled)
if let statusText = self.watchDirectSetupStatusText {
Text(statusText)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} footer: {
Text(
"The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.")
.font(OpenClawType.footnote)
}
Section("Direct node features") {
SettingsDetailRow("Device", value: "Info and status")
SettingsDetailRow("Notifications", value: "While app is active")
}
}
}
var approvalNotificationsWarningCard: some View {
Section {
VStack(alignment: .leading, spacing: 4) {

View file

@ -5,6 +5,7 @@ import UserNotifications
enum SettingsRoute: Hashable {
case gateway
case appleWatch
case approvals
case permissions
case channels

View file

@ -162,7 +162,7 @@ private struct ExecApprovalPromptCard: View {
return trimmed.isEmpty ? nil : trimmed
}
private func expiresText(_ expiresAtMs: Int?) -> String? {
private func expiresText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
if remainingSeconds <= 0 {

View file

@ -110,7 +110,7 @@ final class NodeAppModel {
let host: String?
let nodeId: String?
let agentId: String?
let expiresAtMs: Int?
let expiresAtMs: Int64?
var allowsAllowAlways: Bool {
self.allowedDecisions.contains("allow-always")
@ -493,6 +493,12 @@ final class NodeAppModel {
var cameraHUDKind: CameraHUDKind?
var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false
private(set) var watchMessagingStatus = WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "notActivated")
init(
screen: ScreenController = ScreenController(),
@ -2295,6 +2301,47 @@ extension NodeAppModel {
}
}
func sendDirectWatchSetup() async throws -> WatchNotificationSendResult {
struct SetupCodeResponse: Decodable {
var setupCode: String
}
guard self.isOperatorGatewayConnected else {
throw NSError(domain: "WatchDirectSetup", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Connect the iPhone to a Gateway first.",
])
}
guard self.hasOperatorAdminScope else {
throw NSError(domain: "WatchDirectSetup", code: 2, userInfo: [
NSLocalizedDescriptionKey: "The iPhone connection needs operator.admin access.",
])
}
let status = await watchMessagingService.status()
guard status.supported, status.paired, status.appInstalled else {
throw NSError(domain: "WatchDirectSetup", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pair an Apple Watch and install the OpenClaw watch app first.",
])
}
let response = try await operatorGateway.request(
method: "device.pair.setupCode",
paramsJSON: #"{"includeQr":false,"bootstrapProfile":"node"}"#,
timeoutSeconds: 20)
let setup = try JSONDecoder().decode(SetupCodeResponse.self, from: response)
guard let setupLink = GatewayConnectDeepLink.fromSetupCode(setup.setupCode),
setupLink.connectionEndpoints.contains(where: \.tls)
else {
throw NSError(domain: "WatchDirectSetup", code: 4, userInfo: [
NSLocalizedDescriptionKey: "Direct Apple Watch mode requires a trusted HTTPS Gateway endpoint.",
])
}
return try await self.watchMessagingService.sendDirectNodeSetup(setupCode: setup.setupCode)
}
func refreshWatchMessagingStatus() async {
self.watchMessagingStatus = await self.watchMessagingService.status()
}
private func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off
@ -4359,8 +4406,8 @@ extension NodeAppModel {
private func persistWatchExecApprovalBridgeState() {
self.pruneExpiredWatchExecApprovalPrompts()
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
let lhsExpires = lhs.expiresAtMs ?? Int64.max
let rhsExpires = rhs.expiresAtMs ?? Int64.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
@ -4384,8 +4431,8 @@ extension NodeAppModel {
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
}
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int64? = nil) {
let currentNowMs = nowMs ?? Int64(Date().timeIntervalSince1970 * 1000)
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
guard let expiresAtMs = prompt.expiresAtMs else { return true }
return expiresAtMs > currentNowMs
@ -4393,6 +4440,7 @@ extension NodeAppModel {
}
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
self.watchMessagingStatus = status
GatewayDiagnostics.log(
"watch exec approval: status changed "
+ "reachable=\(status.reachable) activation=\(status.activationState) "
@ -4493,7 +4541,7 @@ extension NodeAppModel {
let deliveryGeneration = self.gatewayConnectGeneration
let message = OpenClawWatchExecApprovalPromptMessage(
approval: Self.makeWatchExecApprovalItem(from: prompt),
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
deliveryId: UUID().uuidString,
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
do {
@ -4533,7 +4581,7 @@ extension NodeAppModel {
approvalId: normalizedApprovalID,
gatewayStableID: gatewayStableID,
decision: decision,
resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
source: source)
do {
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
@ -4562,7 +4610,7 @@ extension NodeAppModel {
approvalId: normalizedApprovalID,
gatewayStableID: gatewayStableID,
reason: reason,
expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
expiredAtMs: Int64(Date().timeIntervalSince1970 * 1000))
do {
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
} catch {
@ -4590,8 +4638,8 @@ extension NodeAppModel {
let approvals = self.watchExecApprovalPromptsByID.values
.filter(self.isExecApprovalPromptCurrent)
.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
let lhsExpires = lhs.expiresAtMs ?? Int64.max
let rhsExpires = rhs.expiresAtMs ?? Int64.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
@ -4601,7 +4649,7 @@ extension NodeAppModel {
let message = OpenClawWatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: currentExecApprovalGatewayStableID(),
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
do {
guard shouldContinue() else { return }
@ -4655,7 +4703,7 @@ extension NodeAppModel {
from raw: [OpenClawKit.AnyCodable],
runId: String,
submittedText: String,
submittedAtMs: Int) -> String?
submittedAtMs: Int64) -> String?
{
let entries = raw.compactMap(self.decodeWatchChatMessage)
if let directReply = entries.last(where: {
@ -4775,7 +4823,7 @@ extension NodeAppModel {
return "\(trimmed.prefix(237))..."
}
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? {
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? {
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
let maxReasonableEpochMs: Double = 32_503_680_000_000
@ -4785,7 +4833,7 @@ extension NodeAppModel {
else {
return nil
}
return Int(milliseconds)
return Int64(milliseconds)
}
private func makeWatchAppSnapshot(
@ -4813,7 +4861,7 @@ extension NodeAppModel {
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
chatItems: chatPreview?.items,
chatStatusText: chatPreview?.statusText,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
}
@ -4998,7 +5046,7 @@ extension NodeAppModel {
let thinking = messageKind == .quickReply ? "low" : "auto"
do {
let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let submittedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
if self.isAppleReviewDemoModeEnabled {
let response = try await appleReviewDemoChatTransport.sendMessage(
sessionKey: sessionKey,
@ -5114,7 +5162,7 @@ extension NodeAppModel {
sessionKey: String,
runId: String,
submittedText: String,
submittedAtMs: Int,
submittedAtMs: Int64,
deadline: Date,
expectedRoute: GatewayNodeSessionRoute) async -> String?
{
@ -5143,7 +5191,7 @@ extension NodeAppModel {
OpenClawWatchChatCompletionMessage(
commandId: commandId,
replyText: replyText,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000)))
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000)))
} catch {
GatewayDiagnostics.log(
"watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)")
@ -5963,7 +6011,7 @@ extension NodeAppModel {
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
var expiresAtMs: Int64?
}
func presentExecApprovalNotificationPrompt(
@ -7143,7 +7191,7 @@ extension NodeAppModel {
from raw: [OpenClawKit.AnyCodable],
runId: String,
submittedText: String,
submittedAtMs: Int) -> String?
submittedAtMs: Int64) -> String?
{
self.watchChatReplyText(
from: raw,
@ -7318,7 +7366,7 @@ extension NodeAppModel {
host: String?,
nodeId: String?,
agentId: String?,
expiresAtMs: Int?) -> ExecApprovalPrompt?
expiresAtMs: Int64?) -> ExecApprovalPrompt?
{
self.makeExecApprovalPrompt(
from: ExecApprovalGetResponse(

View file

@ -660,7 +660,7 @@ extension NodeAppModel {
sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil,
gatewayStableID: (normalizedGatewayStableID?.isEmpty == false) ? normalizedGatewayStableID : nil,
note: "source=ios.notification",
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
transport: "ios.notification")
await _bridgeConsumeMirroredWatchReply(event)
}

View file

@ -84,7 +84,7 @@ struct WatchQuickReplyEvent: Codable, Equatable {
var sessionKey: String?
var gatewayStableID: String?
var note: String?
var sentAtMs: Int?
var sentAtMs: Int64?
var transport: String
}
@ -98,19 +98,19 @@ struct WatchExecApprovalResolveEvent: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: OpenClawWatchExecApprovalDecision
var sentAtMs: Int?
var sentAtMs: Int64?
var transport: String
}
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
var requestId: String
var sentAtMs: Int?
var sentAtMs: Int64?
var transport: String
}
struct WatchAppSnapshotRequestEvent: Equatable {
var requestId: String
var sentAtMs: Int?
var sentAtMs: Int64?
var transport: String
}
@ -120,7 +120,7 @@ struct WatchAppCommandEvent: Codable, Equatable {
var sessionKey: String?
var gatewayStableID: String?
var text: String?
var sentAtMs: Int?
var sentAtMs: Int64?
var transport: String
var messageKind: WatchMessageKind? = nil
}
@ -140,6 +140,7 @@ protocol WatchMessagingServicing: AnyObject, Sendable {
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?)
func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?)
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams,

View file

@ -9,8 +9,8 @@ enum WatchMessagingPayloadCodec {
static let completedChatReplyTextLimit = 4000
static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
static func nonEmpty(_ value: String?) -> String? {
@ -67,6 +67,14 @@ enum WatchMessagingPayloadCodec {
return payload
}
static func encodeDirectNodeSetupPayload(setupCode: String) -> [String: Any] {
[
"type": OpenClawWatchPayloadType.directNodeSetup.rawValue,
"setupCode": setupCode,
"sentAtMs": self.nowMs(),
]
}
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
var payload: [String: Any] = [
"id": item.id,
@ -280,7 +288,7 @@ enum WatchMessagingPayloadCodec {
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let note = self.nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchQuickReplyEvent(
replyId: replyId,
@ -309,7 +317,7 @@ enum WatchMessagingPayloadCodec {
}
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchExecApprovalResolveEvent(
replyId: replyId,
approvalId: approvalId,
@ -327,7 +335,7 @@ enum WatchMessagingPayloadCodec {
return nil
}
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchExecApprovalSnapshotRequestEvent(
requestId: requestId,
sentAtMs: sentAtMs,
@ -342,7 +350,7 @@ enum WatchMessagingPayloadCodec {
return nil
}
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchAppSnapshotRequestEvent(
requestId: requestId,
sentAtMs: sentAtMs,
@ -365,7 +373,7 @@ enum WatchMessagingPayloadCodec {
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let text = self.nonEmpty(payload["text"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchAppCommandEvent(
commandId: commandId,
command: command,

View file

@ -127,6 +127,11 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
return try await self.transport.sendPayload(payload)
}
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult {
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload(setupCode: setupCode))
}
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{

View file

@ -131,6 +131,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
transport: "sendMessage")
var sendError: Error?
var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)?
var lastDirectNodeSetupCode: String?
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
@ -155,6 +156,11 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
self.statusHandler = handler
}
func emitStatus(_ status: WatchMessagingStatus) {
self.currentStatus = status
self.statusHandler?(status)
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.replyHandler = handler
}
@ -189,6 +195,14 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
return self.nextSendResult
}
func sendDirectNodeSetup(setupCode: String) async throws -> WatchNotificationSendResult {
self.lastDirectNodeSetupCode = setupCode
if let sendError {
throw sendError
}
return self.nextSendResult
}
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{
@ -942,7 +956,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
@Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
@ -970,7 +984,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
@Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
@ -1764,7 +1778,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
sessionKey: "main",
gatewayStableID: nil,
text: "Message \(index)",
sentAtMs: index,
sentAtMs: Int64(index),
transport: "sendMessage")
if case .forward = coordinator.ingest(
event,
@ -1830,7 +1844,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
sessionKey: "main",
gatewayStableID: nil,
text: "Queued \(index)",
sentAtMs: index,
sentAtMs: Int64(index),
transport: "transferUserInfo")
if case .queue = coordinator.ingest(
event,
@ -1920,7 +1934,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000)))
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
}
@ -1996,7 +2010,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
expiresAtMs: Int64(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
@ -2484,6 +2498,38 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
#expect(payload.activationState == "inactive")
}
@Test @MainActor func `watch status refresh publishes service snapshot`() async {
let watchService = MockWatchMessagingService()
let status = WatchMessagingStatus(
supported: true,
paired: true,
appInstalled: true,
reachable: false,
activationState: "activated")
watchService.currentStatus = status
let appModel = NodeAppModel(watchMessagingService: watchService)
await appModel.refreshWatchMessagingStatus()
#expect(appModel.watchMessagingStatus == status)
}
@Test @MainActor func `watch status callback publishes reachability changes`() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let status = WatchMessagingStatus(
supported: true,
paired: true,
appInstalled: true,
reachable: true,
activationState: "activated")
watchService.emitStatus(status)
await waitForMainActorWork { appModel.watchMessagingStatus == status }
#expect(appModel.watchMessagingStatus == status)
}
@Test @MainActor func `handle invoke watch notify routes to watch service`() async throws {
let watchService = MockWatchMessagingService()
watchService.nextSendResult = WatchNotificationSendResult(
@ -2573,6 +2619,54 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
#expect(expired["gatewayStableID"] as? String == "gateway-a")
}
@Test @MainActor func `watch direct node setup codec carries opaque setup code`() {
let payload = WatchMessagingPayloadCodec.encodeDirectNodeSetupPayload(
setupCode: "opaque-bootstrap-code")
#expect(payload["type"] as? String == OpenClawWatchPayloadType.directNodeSetup.rawValue)
#expect(payload["setupCode"] as? String == "opaque-bootstrap-code")
#expect(payload["sentAtMs"] is Int64)
#expect(payload["token"] == nil)
#expect(payload["password"] == nil)
}
@Test @MainActor func `watch payload codec preserves 64 bit epoch milliseconds`() throws {
let sentAtMs: Int64 = 1_725_000_000_123
let encodedTimestamp = NSNumber(value: sentAtMs)
let reply = try #require(WatchMessagingPayloadCodec.parseQuickReplyPayload([
"type": OpenClawWatchPayloadType.reply.rawValue,
"actionId": "approve",
"sentAtMs": encodedTimestamp,
], transport: "sendMessage"))
let resolution = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([
"type": OpenClawWatchPayloadType.execApprovalResolve.rawValue,
"approvalId": "approval-a",
"decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue,
"sentAtMs": encodedTimestamp,
], transport: "sendMessage"))
let approvalSnapshotRequest = try #require(
WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([
"type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue,
"sentAtMs": encodedTimestamp,
], transport: "sendMessage"))
let appSnapshotRequest = try #require(WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload([
"type": OpenClawWatchPayloadType.appSnapshotRequest.rawValue,
"sentAtMs": encodedTimestamp,
], transport: "sendMessage"))
let appCommand = try #require(WatchMessagingPayloadCodec.parseAppCommandPayload([
"type": OpenClawWatchPayloadType.appCommand.rawValue,
"command": OpenClawWatchAppCommand.refresh.rawValue,
"sentAtMs": encodedTimestamp,
], transport: "sendMessage"))
#expect(reply.sentAtMs == sentAtMs)
#expect(resolution.sentAtMs == sentAtMs)
#expect(approvalSnapshotRequest.sentAtMs == sentAtMs)
#expect(appSnapshotRequest.sentAtMs == sentAtMs)
#expect(appCommand.sentAtMs == sentAtMs)
}
@Test @MainActor func `watch application context retains app and approval snapshots`() throws {
let appPayload = WatchMessagingPayloadCodec.encodeAppSnapshotPayload(
OpenClawWatchAppSnapshotMessage(
@ -2838,7 +2932,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
sessionKey: "main",
gatewayStableID: "gateway-a",
text: "Pending \(index)",
sentAtMs: index + 2,
sentAtMs: Int64(index + 2),
transport: "transferUserInfo")
_ = firstOutbox.ingest(pending, isAvailable: false, gatewayStableID: "gateway-a")
}

View file

@ -26,4 +26,15 @@ struct WatchDeferredPayloadOrderingTests {
@Test func `replays missing timestamps first in receipt order`() {
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1])
}
@Test func `epoch values preserve ordering above 32 bit range`() {
let earlier: Int64 = 1_700_000_000_000
let later = earlier + 1
#expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: earlier, nowMs: later))
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(
payloadSentAtMs: later,
snapshotSentAtMs: earlier))
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [later, earlier]) == [1, 0])
}
}

View file

@ -20,6 +20,10 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSLocalNetworkUsageDescription</key>
<string>OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks.</string>
<key>WKPrefersNetworkUponForeground</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>RedHatDisplay[wght].ttf</string>

View file

@ -1,10 +1,25 @@
import SwiftUI
import UserNotifications
private final class WatchNotificationPresentationDelegate: NSObject, UNUserNotificationCenterDelegate,
@unchecked Sendable
{
func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler([.banner, .list, .sound])
}
}
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore(
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
@State private var directNode = WatchDirectNode()
@State private var notificationDelegate = WatchNotificationPresentationDelegate()
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
@ -18,6 +33,7 @@ struct OpenClawWatchApp: App {
WindowGroup {
WatchInboxView(
store: self.inboxStore,
directNode: self.directNode,
onAction: { action in
guard let receiver = self.receiver else { return }
let draft = self.inboxStore.makeReplyDraft(action: action)
@ -54,22 +70,37 @@ struct OpenClawWatchApp: App {
self.sendChatMessage(text)
})
.task {
UNUserNotificationCenter.current().delegate = self.notificationDelegate
if OpenClawWatchApp.isScreenshotMode {
self.inboxStore.configureScreenshotFixture()
return
}
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
let receiver = WatchConnectivityReceiver(
store: self.inboxStore,
directNodeSetupHandler: { [weak directNode] setupCode, sentAtMs in
directNode?.configure(setupCode: setupCode, sentAtMs: sentAtMs)
})
receiver.activate()
self.receiver = receiver
}
if self.scenePhase == .active {
self.directNode.connectForForeground()
}
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
}
.onChange(of: self.scenePhase) { _, newPhase in
guard newPhase == .active else { return }
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
switch newPhase {
case .active:
self.directNode.connectForForeground()
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
case .inactive, .background:
self.directNode.disconnectForBackground()
@unknown default:
break
}
}
}
}
@ -144,7 +175,7 @@ struct OpenClawWatchApp: App {
@MainActor
extension WatchInboxStore {
fileprivate func configureScreenshotFixture() {
let sentAtMs = Int(Date().timeIntervalSince1970 * 1000)
let sentAtMs = Int64(Date().timeIntervalSince1970 * 1000)
greetingTextOverride = "Good morning"
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(

View file

@ -9,7 +9,7 @@ struct WatchReplyDraft {
var sessionKey: String?
var gatewayStableID: String?
var note: String?
var sentAtMs: Int
var sentAtMs: Int64
}
struct WatchReplySendResult: Equatable {
@ -25,9 +25,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
private let store: WatchInboxStore
private let session: WCSession?
private let activationGate = WatchSessionActivationGate()
private let directNodeSetupHandler: @MainActor @Sendable (String, Int64) -> Void
init(store: WatchInboxStore) {
init(
store: WatchInboxStore,
directNodeSetupHandler: @escaping @MainActor @Sendable (String, Int64) -> Void)
{
self.store = store
self.directNodeSetupHandler = directNodeSetupHandler
if WCSession.isSupported() {
self.session = WCSession.default
} else {
@ -203,8 +208,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
errorMessage: error.localizedDescription)
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
private static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private static func normalizeObject(_ value: Any) -> [String: Any]? {
@ -261,7 +266,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let id = (payload["id"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let promptId = (payload["promptId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
@ -272,7 +277,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines)
let details = (payload["details"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
let risk = (payload["risk"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let actions = Self.parseActions(payload["actions"])
@ -314,7 +319,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
@ -342,7 +347,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
else {
return nil
}
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resetResolvingState = payload["resetResolvingState"] as? Bool
return WatchExecApprovalPromptMessage(
@ -365,8 +370,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let decision = Self.parseExecApprovalDecision(payload["decision"])
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
let resolvedAtMs = (payload["resolvedAtMs"] as? NSNumber)?.int64Value
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
@ -391,7 +395,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
else {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
let expiredAtMs = (payload["expiredAtMs"] as? NSNumber)?.int64Value
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalExpiredMessage(
@ -414,7 +418,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
@ -446,7 +450,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
let chatStatusText = (payload["chatStatusText"] as? String)?
@ -481,8 +485,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let replyText = (payload["replyText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !commandId.isEmpty, !replyText.isEmpty else { return nil }
let sentAtMs = (payload["sentAtMs"] as? Int)
?? (payload["sentAtMs"] as? NSNumber)?.intValue
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchChatCompletionMessage(
commandId: commandId,
replyText: replyText,
@ -499,7 +502,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? Int) ?? (dict["timestampMs"] as? NSNumber)?.intValue
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
@ -632,6 +635,16 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
}
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
if let type = payload["type"] as? String,
type == WatchPayloadType.directNodeSetup.rawValue,
let setupCode = payload["setupCode"] as? String,
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
{
Task { @MainActor in
self.directNodeSetupHandler(setupCode, sentAtMs)
}
return
}
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
.flatMap(Self.parseAppSnapshotPayload)
let execApprovalSnapshot =

View file

@ -1,19 +1,19 @@
enum WatchDeferredPayloadOrdering {
static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool {
static func isExpired(expiresAtMs: Int64?, nowMs: Int64) -> Bool {
expiresAtMs.map { $0 <= nowMs } == true
}
static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
static func isNewerThanSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool {
guard let payloadSentAtMs, let snapshotSentAtMs else { return true }
return payloadSentAtMs > snapshotSentAtMs
}
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int64?, snapshotSentAtMs: Int64?) -> Bool {
guard let snapshotSentAtMs else { return false }
return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true
}
static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] {
static func indicesOldestFirst(for timestamps: [Int64?]) -> [Int] {
timestamps.indices.sorted { lhs, rhs in
let lhsTimestamp = timestamps[lhs] ?? .min
let rhsTimestamp = timestamps[rhs] ?? .min

View file

@ -0,0 +1,820 @@
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import UserNotifications
import WatchKit
@MainActor @Observable
final class WatchDirectNode {
private struct PersistedConfiguration: Codable {
var link: GatewayConnectDeepLink
var gatewayID: String
var setupSentAtMs: Int64?
}
private struct ActiveSession: Equatable {
let baseURL: URL
let token: String
}
private struct ConnectResponse: Decodable {
let sessionToken: String
let deviceToken: String
}
private enum ConnectCredential {
case bootstrap(String)
case device(String)
var token: String {
switch self {
case let .bootstrap(token), let .device(token): token
}
}
}
private struct ChallengeResponse: Decodable {
let nonce: String
}
private struct PollResponse: Decodable {
let event: NodeEvent?
}
private struct NodeEvent: Decodable {
let event: String
let payload: InvokeRequest?
}
private struct InvokeRequest: Decodable {
let id: String
let nodeId: String
let command: String
let paramsJSON: String?
}
private struct HTTPError: LocalizedError {
let status: Int
let detail: String
var errorDescription: String? {
self.detail.isEmpty ? "Gateway HTTP error (\(self.status))" : self.detail
}
}
private static let keychainService = "ai.openclaw.watch.direct-node"
private static let keychainAccount = "gateway"
private static let enabledDefaultsKey = "watch.directNode.enabled"
private static let lastSetupSentAtDefaultsKey = "watch.directNode.lastSetupSentAtMs"
private static let maximumSetupAgeMs: Int64 = 12 * 60 * 1000
private static let maximumSetupClockSkewMs: Int64 = 2 * 60 * 1000
private static let commands = [
OpenClawDeviceCommand.info.rawValue,
OpenClawDeviceCommand.status.rawValue,
OpenClawSystemCommand.notify.rawValue,
]
private let networkMetrics: WatchURLSessionMetrics
private let urlSession: URLSession
private var configuration: PersistedConfiguration?
private var connectTask: Task<Void, Never>?
private var activeSession: ActiveSession?
private var isForeground = false
private var connectionGeneration = 0
private(set) var isEnabled: Bool
private(set) var isConnected = false
private(set) var statusText = "Use iPhone Settings to enable direct connection."
private(set) var endpointText: String?
var isConfigured: Bool {
self.configuration != nil
}
init() {
let sessionConfiguration = URLSessionConfiguration.ephemeral
sessionConfiguration.waitsForConnectivity = true
sessionConfiguration.timeoutIntervalForRequest = 30
sessionConfiguration.timeoutIntervalForResource = 35
let networkMetrics = WatchURLSessionMetrics()
self.networkMetrics = networkMetrics
self.urlSession = URLSession(
configuration: sessionConfiguration,
delegate: networkMetrics,
delegateQueue: nil)
self.isEnabled = UserDefaults.standard.bool(forKey: Self.enabledDefaultsKey)
self.configuration = Self.loadConfiguration()
if let setupSentAtMs = configuration?.setupSentAtMs,
setupSentAtMs > Self.lastAcceptedSetupSentAtMs()
{
Self.saveLastAcceptedSetupSentAtMs(setupSentAtMs)
}
self.endpointText = self.configuration.map(Self.endpointText)
if self.configuration != nil {
self.statusText = self.isEnabled ? "Ready to connect" : "Direct connection is off"
}
}
func configure(setupCode: String, sentAtMs: Int64) {
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let oldestAcceptedMs = nowMs - Self.maximumSetupAgeMs
let newestAcceptedMs = nowMs + Self.maximumSetupClockSkewMs
guard (oldestAcceptedMs...newestAcceptedMs).contains(sentAtMs) else {
self.statusText = "Ignored an expired direct connection setup. Send setup again from iPhone."
return
}
let newestInstalledSetupMs = configuration?.setupSentAtMs ?? 0
guard sentAtMs > max(Self.lastAcceptedSetupSentAtMs(), newestInstalledSetupMs) else { return }
guard let link = GatewayConnectDeepLink.fromSetupCode(setupCode),
link.isValidEndpoint,
link.bootstrapToken != nil,
link.token == nil,
link.password == nil,
let secureEndpoint = link.connectionEndpoints.first(where: \.tls)
else {
self.statusText = "Direct mode requires a trusted HTTPS Gateway endpoint."
return
}
let secureEndpoints = link.connectionEndpoints.filter(\.tls)
let secureLink = GatewayConnectDeepLink(
host: secureEndpoint.host,
port: secureEndpoint.port,
tls: true,
bootstrapToken: link.bootstrapToken,
token: nil,
password: nil,
fallbackEndpoints: Array(secureEndpoints.dropFirst()))
let previousConfiguration = self.configuration
let configuration = PersistedConfiguration(
link: secureLink,
gatewayID: Self.gatewayID(for: secureLink),
setupSentAtMs: sentAtMs)
guard Self.saveConfiguration(configuration) else {
self.statusText = "Could not save direct connection securely."
return
}
if let previousConfiguration,
previousConfiguration.gatewayID != configuration.gatewayID,
let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
{
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: previousConfiguration.gatewayID,
profile: .primary)
}
Self.saveLastAcceptedSetupSentAtMs(sentAtMs)
self.disconnectActiveSession()
self.configuration = configuration
self.endpointText = Self.endpointText(configuration)
self.statusText = "Setup received. Connecting…"
self.setEnabled(true)
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
UserDefaults.standard.set(enabled, forKey: Self.enabledDefaultsKey)
if enabled {
self.connect()
} else {
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
self.isConnected = false
self.statusText = self.isConfigured
? "Direct connection is off"
: "Use iPhone Settings to enable direct connection."
}
}
func connect() {
guard self.isForeground, self.isEnabled, let configuration else { return }
self.disconnectActiveSession()
self.connectTask?.cancel()
self.connectionGeneration &+= 1
let generation = self.connectionGeneration
self.connectTask = Task { [weak self] in
await self?.run(configuration, generation: generation)
}
}
func connectForForeground() {
self.isForeground = true
self.connect()
}
func disconnectForBackground() {
self.isForeground = false
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
self.isConnected = false
if self.isEnabled, self.isConfigured {
self.statusText = "Reconnects when OpenClaw is active"
}
}
func forget() {
self.disconnectActiveSession()
self.connectionGeneration &+= 1
self.connectTask?.cancel()
self.connectTask = nil
if let configuration {
if let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) {
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: configuration.gatewayID,
profile: .primary)
}
}
_ = GenericPasswordKeychainStore.delete(
service: Self.keychainService,
account: Self.keychainAccount)
configuration = nil
self.endpointText = nil
self.isConnected = false
self.setEnabled(false)
}
private func run(_ configuration: PersistedConfiguration, generation: Int) async {
while self.isCurrentConnection(generation, configuration: configuration) {
var lastError: Error?
for endpoint in configuration.link.connectionEndpoints {
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
let link = configuration.link.selectingEndpoint(endpoint)
guard let baseURL = Self.httpBaseURL(for: link) else { continue }
do {
try await self.connectAndPoll(
configuration: configuration,
link: link,
baseURL: baseURL,
generation: generation)
return
} catch is CancellationError {
return
} catch let error as URLError where error.code == .cancelled {
return
} catch {
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
lastError = error
self.isConnected = false
}
}
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
self.statusText = lastError.map {
"Direct connection failed: \($0.localizedDescription)"
} ?? "No usable Gateway endpoint"
do {
try await Task.sleep(for: .seconds(3))
} catch {
return
}
}
}
private func connectAndPoll(
configuration: PersistedConfiguration,
link: GatewayConnectDeepLink,
baseURL: URL,
generation: Int) async throws
{
try self.requireCurrentConnection(generation, configuration: configuration)
self.statusText = "Connecting directly…"
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) else {
throw HTTPError(status: 0, detail: "Could not save the watch device identity")
}
let storedToken = DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: configuration.gatewayID,
profile: .primary)?.token
guard storedToken != nil || link.bootstrapToken != nil else {
throw HTTPError(status: 401, detail: "No watch device credential")
}
let response: ConnectResponse
if let bootstrapToken = link.bootstrapToken {
do {
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .bootstrap(bootstrapToken))
} catch let error as HTTPError where error.status == 401 {
guard let storedToken else { throw error }
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .device(storedToken))
}
} else if let storedToken {
response = try await self.establishSession(
identity: identity,
baseURL: baseURL,
credential: .device(storedToken))
} else {
throw HTTPError(status: 401, detail: "No watch device credential")
}
// A successful bootstrap response has already consumed the one-time code.
// Finish that durable handoff across background/toggle cancellation, but
// never let an obsolete attempt overwrite a forgotten or newer setup.
do {
guard self.isInstalledConfiguration(configuration) else { throw CancellationError() }
guard DeviceAuthStore.storeTokenPersisted(
deviceId: identity.deviceId,
role: "node",
token: response.deviceToken,
scopes: [],
gatewayID: configuration.gatewayID,
profile: .primary)
else {
throw HTTPError(status: 0, detail: "Could not save the watch device credential")
}
if link.bootstrapToken != nil {
try self.finishCredentialHandoff(configuration: configuration)
}
try self.requireCurrentConnection(generation, configuration: configuration)
} catch {
self.sendDisconnect(ActiveSession(baseURL: baseURL, token: response.sessionToken))
throw error
}
let session = ActiveSession(baseURL: baseURL, token: response.sessionToken)
self.activeSession = session
defer { releaseActiveSession(session) }
self.isConnected = true
self.statusText = "Connected directly"
while self.isCurrentConnection(generation, configuration: configuration) {
let pollData = try await request(
baseURL: baseURL,
path: "poll",
method: "POST",
token: response.sessionToken)
try self.requireCurrentConnection(generation, configuration: configuration)
let poll = try JSONDecoder().decode(PollResponse.self, from: pollData)
guard let event = poll.event else { continue }
guard event.event == "node.invoke.request", let invoke = event.payload else { continue }
let invokeRequest = BridgeInvokeRequest(
id: invoke.id,
command: invoke.command,
paramsJSON: invoke.paramsJSON,
nodeId: invoke.nodeId)
let result = await handleInvoke(invokeRequest)
try requireCurrentConnection(generation, configuration: configuration)
_ = try await self.request(
baseURL: baseURL,
path: "result",
method: "POST",
token: response.sessionToken,
body: result)
try self.requireCurrentConnection(generation, configuration: configuration)
}
}
private func isCurrentConnection(
_ generation: Int,
configuration: PersistedConfiguration) -> Bool
{
!Task.isCancelled
&& generation == self.connectionGeneration
&& self.isForeground
&& self.isEnabled
&& self.isInstalledConfiguration(configuration)
}
private func isInstalledConfiguration(_ configuration: PersistedConfiguration) -> Bool {
configuration.gatewayID == self.configuration?.gatewayID
&& configuration.setupSentAtMs == self.configuration?.setupSentAtMs
}
private func requireCurrentConnection(
_ generation: Int,
configuration: PersistedConfiguration) throws
{
guard self.isCurrentConnection(generation, configuration: configuration) else {
throw CancellationError()
}
}
private func establishSession(
identity: DeviceIdentity,
baseURL: URL,
credential: ConnectCredential) async throws -> ConnectResponse
{
let challengeData = try await request(
baseURL: baseURL,
path: "challenge",
method: "GET",
token: nil)
let challenge = try JSONDecoder().decode(ChallengeResponse.self, from: challengeData)
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
let params = try connectParams(
identity: identity,
nonce: challenge.nonce,
credential: credential,
notificationsAuthorized: notificationSettings.authorizationStatus == .authorized
|| notificationSettings.authorizationStatus == .provisional)
let connectData = try await request(
baseURL: baseURL,
path: "connect",
method: "POST",
token: nil,
body: params)
return try JSONDecoder().decode(ConnectResponse.self, from: connectData)
}
private func connectParams(
identity: DeviceIdentity,
nonce: String,
credential: ConnectCredential,
notificationsAuthorized: Bool) throws -> ConnectParams
{
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: "openclaw-watchos",
clientMode: "node",
role: "node",
scopes: [],
signedAtMs: signedAtMs,
token: credential.token,
nonce: nonce,
platform: InstanceIdentity.platformString,
deviceFamily: InstanceIdentity.deviceFamily)
guard let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: nonce)
else {
throw HTTPError(status: 0, detail: "Could not sign watch identity")
}
var client: [String: AnyCodable] = [
"id": AnyCodable("openclaw-watchos"),
"displayName": AnyCodable(InstanceIdentity.displayName),
"version": AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": AnyCodable(InstanceIdentity.platformString),
"deviceFamily": AnyCodable(InstanceIdentity.deviceFamily),
"mode": AnyCodable("node"),
"instanceId": AnyCodable(InstanceIdentity.instanceId),
]
if let modelIdentifier = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = AnyCodable(modelIdentifier)
}
let auth: [String: AnyCodable] = switch credential {
case let .device(token):
["deviceToken": AnyCodable(token)]
case let .bootstrap(token):
["bootstrapToken": AnyCodable(token)]
}
return ConnectParams(
minprotocol: GATEWAY_MIN_PROTOCOL_VERSION,
maxprotocol: GATEWAY_PROTOCOL_VERSION,
client: client,
caps: [],
commands: Self.commands,
permissions: ["notifications": AnyCodable(notificationsAuthorized)],
pathenv: nil,
role: "node",
scopes: [],
device: device,
auth: auth,
locale: Locale.preferredLanguages.first ?? Locale.current.identifier,
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
}
private func request(
baseURL: URL,
path: String,
method: String,
token: String?) async throws -> Data
{
try await self.performRequest(
baseURL: baseURL,
path: path,
method: method,
token: token,
body: nil)
}
private func request(
baseURL: URL,
path: String,
method: String,
token: String?,
body: some Encodable) async throws -> Data
{
let encodedBody = try JSONEncoder().encode(body)
return try await self.performRequest(
baseURL: baseURL,
path: path,
method: method,
token: token,
body: encodedBody)
}
private func performRequest(
baseURL: URL,
path: String,
method: String,
token: String?,
body: Data?) async throws -> Data
{
let url = baseURL
.appendingPathComponent("api")
.appendingPathComponent("nodes")
.appendingPathComponent("watch")
.appendingPathComponent(path)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = path == "poll" ? 25 : 8
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HTTPError(status: 0, detail: "Invalid Gateway response")
}
guard (200..<300).contains(http.statusCode) else {
let detail = String(data: data, encoding: .utf8) ?? ""
throw HTTPError(status: http.statusCode, detail: detail)
}
return data
}
private func finishCredentialHandoff(configuration: PersistedConfiguration) throws {
let link = configuration.link
let sanitized = PersistedConfiguration(
link: GatewayConnectDeepLink(
host: link.host,
port: link.port,
tls: link.tls,
bootstrapToken: nil,
token: nil,
password: nil,
fallbackEndpoints: link.fallbackEndpoints),
gatewayID: configuration.gatewayID,
setupSentAtMs: configuration.setupSentAtMs)
guard Self.saveConfiguration(sanitized) else {
throw HTTPError(status: 0, detail: "Paired, but could not finish secure setup")
}
self.configuration = sanitized
self.endpointText = Self.endpointText(sanitized)
}
private func handleInvoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse {
do {
switch request.command {
case OpenClawDeviceCommand.info.rawValue:
return try self.encodedResponse(id: request.id, payload: self.deviceInfo())
case OpenClawDeviceCommand.status.rawValue:
return try self.encodedResponse(id: request.id, payload: self.deviceStatus())
case OpenClawSystemCommand.notify.rawValue:
return try await self.handleNotification(request)
default:
return Self.errorResponse(
id: request.id,
code: .invalidRequest,
message: "INVALID_REQUEST: unsupported watchOS command")
}
} catch {
return Self.errorResponse(
id: request.id,
code: .unavailable,
message: error.localizedDescription)
}
}
private func handleNotification(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decode(OpenClawSystemNotifyParams.self, from: request.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty || !body.isEmpty else {
return Self.errorResponse(
id: request.id,
code: .invalidRequest,
message: "INVALID_REQUEST: empty notification")
}
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
return Self.errorResponse(
id: request.id,
code: .unavailable,
message: "NOT_AUTHORIZED: notifications")
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
switch params.priority ?? .active {
case .passive:
content.interruptionLevel = .passive
case .timeSensitive:
content.interruptionLevel = .timeSensitive
case .active:
content.interruptionLevel = .active
}
let sound = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
content.sound = sound.map { ["none", "silent", "off"].contains($0) } == true ? nil : .default
try await center.add(UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil))
return BridgeInvokeResponse(id: request.id, ok: true)
}
private func deviceInfo() -> OpenClawDeviceInfoPayload {
let device = WKInterfaceDevice.current()
let info = Bundle.main.infoDictionary ?? [:]
let appVersion = (info["CFBundleShortVersionString"] as? String) ?? "0"
let appBuild = (info["CFBundleVersion"] as? String) ?? "0"
return OpenClawDeviceInfoPayload(
deviceName: device.name,
modelIdentifier: InstanceIdentity.modelIdentifier ?? "Apple Watch",
systemName: "watchOS",
systemVersion: device.systemVersion,
appVersion: appVersion,
appBuild: appBuild,
locale: Locale.preferredLanguages.first ?? Locale.current.identifier)
}
private func deviceStatus() -> OpenClawDeviceStatusPayload {
let device = WKInterfaceDevice.current()
device.isBatteryMonitoringEnabled = true
let batteryState: OpenClawBatteryState = switch device.batteryState {
case .charging: .charging
case .full: .full
case .unplugged: .unplugged
case .unknown: .unknown
@unknown default: .unknown
}
let battery = OpenClawBatteryStatusPayload(
level: device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil,
state: batteryState,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
let thermalState: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
case .nominal: .nominal
case .fair: .fair
case .serious: .serious
case .critical: .critical
@unknown default: .nominal
}
let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
let total = (attributes[.systemSize] as? NSNumber)?.int64Value ?? 0
let free = (attributes[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
let networkMetrics = self.networkMetrics.snapshot()
return OpenClawDeviceStatusPayload(
battery: battery,
thermal: OpenClawThermalStatusPayload(state: thermalState),
storage: OpenClawStorageStatusPayload(
totalBytes: total,
freeBytes: free,
usedBytes: max(0, total - free)),
network: OpenClawNetworkStatusPayload(
status: self.isConnected ? .satisfied : .requiresConnection,
isExpensive: networkMetrics?.isExpensive ?? false,
isConstrained: networkMetrics?.isConstrained ?? false,
interfaces: networkMetrics?.isCellular == true ? [.cellular] : [.other]),
uptimeSeconds: ProcessInfo.processInfo.systemUptime)
}
private func encodedResponse(id: String, payload: some Encodable) throws -> BridgeInvokeResponse {
let data = try JSONEncoder().encode(payload)
guard let json = String(data: data, encoding: .utf8) else {
throw CocoaError(.fileWriteInapplicableStringEncoding)
}
return BridgeInvokeResponse(id: id, ok: true, payloadJSON: json)
}
private static func decode<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
try JSONDecoder().decode(type, from: Data((json ?? "{}").utf8))
}
private static func errorResponse(
id: String,
code: OpenClawNodeErrorCode,
message: String) -> BridgeInvokeResponse
{
BridgeInvokeResponse(
id: id,
ok: false,
error: OpenClawNodeError(code: code, message: message))
}
private static func httpBaseURL(for link: GatewayConnectDeepLink) -> URL? {
guard link.tls else { return nil }
var components = URLComponents()
components.scheme = "https"
components.host = link.host
components.port = link.port
return components.url
}
private static func gatewayID(for link: GatewayConnectDeepLink) -> String {
"watch-direct:\(link.tls ? "https" : "http")://\(link.host.lowercased()):\(link.port)"
}
private static func endpointText(_ configuration: PersistedConfiguration) -> String {
"\(configuration.link.tls ? "https" : "http")://\(configuration.link.host):\(configuration.link.port)"
}
private static func loadConfiguration() -> PersistedConfiguration? {
guard let raw = GenericPasswordKeychainStore.loadString(
service: keychainService,
account: keychainAccount),
let data = raw.data(using: .utf8)
else { return nil }
return try? JSONDecoder().decode(PersistedConfiguration.self, from: data)
}
private static func saveConfiguration(_ configuration: PersistedConfiguration) -> Bool {
guard let data = try? JSONEncoder().encode(configuration),
let raw = String(data: data, encoding: .utf8)
else { return false }
return GenericPasswordKeychainStore.saveString(
raw,
service: self.keychainService,
account: self.keychainAccount)
}
private func disconnectActiveSession() {
self.isConnected = false
guard let session = activeSession else { return }
self.activeSession = nil
self.sendDisconnect(session)
}
private func releaseActiveSession(_ session: ActiveSession) {
guard self.activeSession == session else { return }
self.activeSession = nil
self.sendDisconnect(session)
}
private func sendDisconnect(_ session: ActiveSession) {
Task { [weak self] in
_ = try? await self?.request(
baseURL: session.baseURL,
path: "disconnect",
method: "POST",
token: session.token)
}
}
private static func lastAcceptedSetupSentAtMs() -> Int64 {
(UserDefaults.standard.object(forKey: self.lastSetupSentAtDefaultsKey) as? NSNumber)?.int64Value ?? 0
}
private static func saveLastAcceptedSetupSentAtMs(_ sentAtMs: Int64) {
UserDefaults.standard.set(NSNumber(value: sentAtMs), forKey: self.lastSetupSentAtDefaultsKey)
}
}
private struct WatchNetworkMetricsSnapshot {
let isCellular: Bool
let isExpensive: Bool
let isConstrained: Bool
}
private final class WatchURLSessionMetrics: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
private let lock = NSLock()
private var latest: WatchNetworkMetricsSnapshot?
func snapshot() -> WatchNetworkMetricsSnapshot? {
self.lock.lock()
defer { lock.unlock() }
return self.latest
}
func urlSession(
_: URLSession,
task _: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics)
{
guard let transaction = metrics.transactionMetrics.last else { return }
let snapshot = WatchNetworkMetricsSnapshot(
isCellular: transaction.isCellular,
isExpensive: transaction.isExpensive,
isConstrained: transaction.isConstrained)
self.lock.lock()
self.latest = snapshot
self.lock.unlock()
}
func urlSession(
_: URLSession,
task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void)
{
completionHandler(nil)
}
}

View file

@ -5,6 +5,7 @@ import WatchKit
enum WatchPayloadType: String, Codable, Equatable {
case notify = "watch.notify"
case directNodeSetup = "watch.node.setup"
case reply = "watch.reply"
case appSnapshot = "watch.app.snapshot"
case appSnapshotRequest = "watch.app.snapshotRequest"
@ -45,14 +46,14 @@ struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
var expiresAtMs: Int64?
var allowedDecisions: [WatchExecApprovalDecision]
var risk: WatchRiskLevel?
}
struct WatchExecApprovalPromptMessage: Codable, Equatable {
var approval: WatchExecApprovalItem
var sentAtMs: Int?
var sentAtMs: Int64?
var deliveryId: String?
var resetResolvingState: Bool?
}
@ -61,7 +62,7 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int?
var resolvedAtMs: Int64?
var source: String?
}
@ -69,19 +70,19 @@ struct WatchExecApprovalExpiredMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int?
var expiredAtMs: Int64?
}
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
var approvals: [WatchExecApprovalItem]
var gatewayStableID: String?
var sentAtMs: Int?
var sentAtMs: Int64?
var snapshotId: String?
}
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
var requestId: String
var sentAtMs: Int?
var sentAtMs: Int64?
}
struct WatchExecApprovalResolveMessage: Codable, Equatable {
@ -89,7 +90,7 @@ struct WatchExecApprovalResolveMessage: Codable, Equatable {
var gatewayStableID: String?
var decision: WatchExecApprovalDecision
var replyId: String
var sentAtMs: Int?
var sentAtMs: Int64?
}
struct WatchAppSnapshotMessage: Codable, Equatable {
@ -107,26 +108,26 @@ struct WatchAppSnapshotMessage: Codable, Equatable {
var pendingApprovalCount: Int
var chatItems: [WatchChatItem]?
var chatStatusText: String?
var sentAtMs: Int?
var sentAtMs: Int64?
var snapshotId: String?
}
struct WatchChatCompletionMessage: Codable, Equatable {
var commandId: String
var replyText: String
var sentAtMs: Int?
var sentAtMs: Int64?
}
struct WatchChatItem: Codable, Equatable, Identifiable {
var id: String
var role: String
var text: String
var timestampMs: Int?
var timestampMs: Int64?
}
struct WatchAppSnapshotRequestMessage: Codable, Equatable {
var requestId: String
var sentAtMs: Int?
var sentAtMs: Int64?
}
enum WatchAppCommand: String, Codable, Equatable {
@ -143,7 +144,7 @@ struct WatchAppCommandMessage: Codable, Equatable {
var sessionKey: String?
var gatewayStableID: String?
var text: String?
var sentAtMs: Int?
var sentAtMs: Int64?
}
struct WatchPromptAction: Codable, Equatable, Identifiable {
@ -156,13 +157,13 @@ struct WatchNotifyMessage: Codable {
var id: String?
var title: String
var body: String
var sentAtMs: Int?
var sentAtMs: Int64?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
var expiresAtMs: Int64?
var risk: String?
var actions: [WatchPromptAction]
}
@ -208,7 +209,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
}
var sentAtMs: Int? {
var sentAtMs: Int64? {
switch self {
case let .notification(message, _):
message.sentAtMs
@ -223,7 +224,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
}
var expiresAtMs: Int? {
var expiresAtMs: Int64? {
switch self {
case let .notification(message, _):
message.expiresAtMs
@ -260,7 +261,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
var expiresAtMs: Int64?
var risk: String?
var actions: [WatchPromptAction]?
var replyStatusText: String?
@ -269,7 +270,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var selectedExecApprovalID: String?
var lastExecApprovalSnapshotID: String?
var lastExecApprovalSnapshotGatewayStableID: String?
var lastExecApprovalSnapshotSentAtMs: Int?
var lastExecApprovalSnapshotSentAtMs: Int64?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var appSnapshot: WatchAppSnapshotMessage?
@ -294,7 +295,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int?
var expiresAtMs: Int64?
var risk: String?
var actions: [WatchPromptAction] = []
var replyStatusText: String?
@ -315,7 +316,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var execApprovalReviewStatusAt: Date?
private var lastExecApprovalSnapshotID: String?
private var lastExecApprovalSnapshotGatewayStableID: String?
private var lastExecApprovalSnapshotSentAtMs: Int?
private var lastExecApprovalSnapshotSentAtMs: Int64?
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
private var lastDeliveryKey: String?
/// WatchConnectivity does not order application-context updates against user-info
@ -339,8 +340,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var sortedExecApprovals: [WatchExecApprovalRecord] {
self.execApprovals.sorted { lhs, rhs in
let lhsExpires = lhs.approval.expiresAtMs ?? Int.max
let rhsExpires = rhs.approval.expiresAtMs ?? Int.max
let lhsExpires = lhs.approval.expiresAtMs ?? Int64.max
let rhsExpires = rhs.approval.expiresAtMs ?? Int64.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
@ -1003,7 +1004,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
}
private func pruneExpiredExecApprovals(nowMs: Int) {
private func pruneExpiredExecApprovals(nowMs: Int64) {
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
return record.approval
@ -1137,7 +1138,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.defaults.set(data, forKey: Self.persistedStateKey)
}
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String {
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int64?) -> String {
if let messageID, messageID.isEmpty == false {
return "id:\(messageID)"
}
@ -1253,7 +1254,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
private static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
}

View file

@ -3,6 +3,7 @@ import WatchKit
struct WatchInboxView: View {
var store: WatchInboxStore
var directNode: WatchDirectNode
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
@ -14,6 +15,7 @@ struct WatchInboxView: View {
NavigationStack {
WatchControlSurfaceView(
store: self.store,
directNode: self.directNode,
onAction: self.onAction,
onExecApprovalDecision: self.onExecApprovalDecision,
onRefreshExecApprovalReview: self.onRefreshExecApprovalReview,
@ -27,6 +29,7 @@ struct WatchInboxView: View {
private struct WatchControlSurfaceView: View {
var store: WatchInboxStore
var directNode: WatchDirectNode
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
@ -43,6 +46,8 @@ private struct WatchControlSurfaceView: View {
.tag(1)
self.approvalsFace
.tag(2)
self.connectionFace
.tag(3)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.background(WatchClawStyle.background.ignoresSafeArea())
@ -50,7 +55,7 @@ private struct WatchControlSurfaceView: View {
}
private var faceCount: Int {
3
4
}
private var pageRail: some View {
@ -295,6 +300,49 @@ private struct WatchControlSurfaceView: View {
}
}
private var connectionFace: some View {
WatchFaceScroll {
self.pageRail
WatchFaceHeader(
section: "Connection",
title: self.directNode.isConnected ? "Watch node online" : "Direct Gateway",
subtitle: self.directNode.statusText,
isOnline: self.directNode.isConnected,
avatarImageSource: self.avatarImageSource,
avatarText: self.avatarText)
WatchHeroCard(
label: self.directNode.isConnected ? "Direct" : "Setup",
title: self.directNode.endpointText ?? "Enable from iPhone",
subtitle: self.directNode.isConfigured
? "Uses Wi-Fi or cellular while OpenClaw is active"
: "Open iPhone Settings → Apple Watch",
accessory: self.directNode.isConnected ? "Online" : "Offline")
WatchDetailText(
text: "Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.")
if self.directNode.isConfigured {
Toggle(isOn: Binding(
get: { self.directNode.isEnabled },
set: { self.directNode.setEnabled($0) }))
{
Text("Direct connection")
.font(WatchClawType.body(size: 13))
}
.tint(WatchClawStyle.accent)
.padding(.horizontal, 8)
WatchSecondaryButton(title: "Forget direct setup") {
self.directNode.forget()
}
} else {
WatchDetailText(
text: "The iPhone securely sends a one-time setup code. Existing relay features stay available.")
}
}
}
private var chatItems: [WatchChatItem] {
self.store.appSnapshot?.chatItems ?? []
}
@ -497,9 +545,9 @@ private struct WatchControlSurfaceView: View {
}
}
private func expiryText(_ expiresAtMs: Int?) -> String? {
private func expiryText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1m"
}
@ -1346,9 +1394,9 @@ private struct WatchExecApprovalListView: View {
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
private static func expiresText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "Expires in <1m"
}
@ -1437,9 +1485,9 @@ private struct WatchExecApprovalDetailView: View {
}
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
private static func expiresText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1 minute"
}

View file

@ -318,6 +318,7 @@ targets:
- path: Resources/Localizable.xcstrings
buildPhase: resources
dependencies:
- package: OpenClawKit
- sdk: AppIntents.framework
- sdk: AVFAudio.framework
- sdk: WatchConnectivity.framework
@ -356,6 +357,8 @@ targets:
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_COMPANION_APP_BUNDLE_ID)"
WKApplication: true
WKPrefersNetworkUponForeground: true
NSLocalNetworkUsageDescription: OpenClaw connects this Apple Watch directly to your Gateway on trusted local networks.
UIAppFonts:
- RedHatDisplay[wght].ttf
- Inter[opsz,wght].ttf

View file

@ -273,7 +273,7 @@ actor GatewayWizardClient {
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
deviceId: identity.deviceId,
clientId: clientId,

View file

@ -7,6 +7,7 @@ let package = Package(
platforms: [
.iOS(.v18),
.macOS(.v15),
.watchOS(.v11),
],
products: [
.library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]),
@ -33,7 +34,10 @@ let package = Package(
name: "OpenClawKit",
dependencies: [
"OpenClawProtocol",
.product(name: "ElevenLabsKit", package: "ElevenLabsKit", condition: .when(traits: ["Talk"])),
.product(
name: "ElevenLabsKit",
package: "ElevenLabsKit",
condition: .when(platforms: [.iOS, .macOS], traits: ["Talk"])),
],
path: "Sources/OpenClawKit",
resources: [

View file

@ -1,4 +1,4 @@
#if Talk
#if Talk && canImport(ElevenLabsKit)
import Foundation
@MainActor

View file

@ -1,5 +1,6 @@
import Foundation
#if !os(watchOS)
public enum BonjourServiceResolverSupport {
public static func start(_ service: NetService, timeout: TimeInterval = 2.0) {
service.schedule(in: .main, forMode: .common)
@ -12,3 +13,4 @@ public enum BonjourServiceResolverSupport {
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
}
}
#endif

View file

@ -1,5 +1,6 @@
import AVFoundation
#if !os(watchOS)
public enum CameraAuthorization {
public static func isAuthorized(for mediaType: AVMediaType) async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
@ -19,3 +20,4 @@ public enum CameraAuthorization {
}
}
}
#endif

View file

@ -1,6 +1,7 @@
import AVFoundation
import Foundation
#if !os(watchOS)
public enum CameraCapturePipelineSupport {
public static func preparePhotoSession(
preferFrontCamera: Bool,
@ -149,3 +150,4 @@ public enum CameraCapturePipelineSupport {
}
}
}
#endif

View file

@ -1,6 +1,7 @@
import AVFoundation
import CoreMedia
#if !os(watchOS)
public enum CameraSessionConfigurationError: LocalizedError {
case addCameraInputFailed
case addPhotoOutputFailed
@ -68,3 +69,4 @@ public enum CameraSessionConfiguration {
return output
}
}
#endif

View file

@ -8,7 +8,7 @@ public enum GatewayDeviceAuthPayload {
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
signedAtMs: Int64,
token: String?,
nonce: String) -> String
{
@ -36,7 +36,7 @@ public enum GatewayDeviceAuthPayload {
clientMode: String,
role: String,
scopes: [String],
signedAtMs: Int,
signedAtMs: Int64,
token: String?,
nonce: String,
platform: String?,
@ -85,7 +85,7 @@ public enum GatewayDeviceAuthPayload {
public static func signedDeviceDictionary(
payload: String,
identity: DeviceIdentity,
signedAtMs: Int,
signedAtMs: Int64,
nonce: String) -> [String: OpenClawProtocol.AnyCodable]?
{
guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity),

View file

@ -4,10 +4,10 @@ public struct DeviceAuthEntry: Codable, Sendable {
public let token: String
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public let updatedAtMs: Int64
public let gatewayID: String?
public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) {
public init(token: String, role: String, scopes: [String], updatedAtMs: Int64, gatewayID: String? = nil) {
self.token = token
self.role = role
self.scopes = scopes
@ -50,6 +50,25 @@ public enum DeviceAuthStore {
profile: profile).entry
}
/// Stores a token and reports whether the durable write succeeded.
@discardableResult
public static func storeTokenPersisted(
deviceId: String,
role: String,
token: String,
scopes: [String] = [],
gatewayID: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary) -> Bool
{
self.storeTokenResult(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
gatewayID: gatewayID,
profile: profile).persisted
}
static func storeTokenResult(
deviceId: String,
role: String,
@ -67,7 +86,7 @@ public enum DeviceAuthStore {
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000),
updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
gatewayID: self.normalizeGatewayID(gatewayID))
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])

View file

@ -36,9 +36,9 @@ public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
public var privateKey: String
public var createdAtMs: Int
public var createdAtMs: Int64
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int64) {
self.deviceId = deviceId
self.publicKey = publicKey
self.privateKey = privateKey
@ -196,6 +196,15 @@ public enum DeviceIdentityStore {
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
}
/// Loads or creates an identity, returning nil unless its key material was durably persisted.
public static func loadOrCreatePersisted(
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceIdentity?
{
self.loadOrCreatePersisted(
fileURL: self.fileURL(profile: profile),
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
}
static func loadOrCreate(
fileURL url: URL,
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity
@ -221,6 +230,22 @@ public enum DeviceIdentityStore {
return identity
}
static func loadOrCreatePersisted(
fileURL url: URL,
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity?
{
let identity = self.loadOrCreate(fileURL: url, migrationSource: migrationSource)
guard let data = try? Data(contentsOf: url),
case let .identity(stored) = self.decodeStoredIdentity(data),
stored.deviceId == identity.deviceId,
stored.publicKey == identity.publicKey,
stored.privateKey == identity.privateKey
else {
return nil
}
return stored
}
/// One-time upgrade path for builds that lost App Group storage: it runs only while the
/// selected store has no identity file, so steady state never re-reads the old container.
private static func migratedIdentity(
@ -314,7 +339,7 @@ public enum DeviceIdentityStore {
deviceId: deviceId,
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
createdAtMs: Int64(Date().timeIntervalSince1970 * 1000))
}
private static func base64UrlEncode(_ data: Data) -> String {
@ -417,5 +442,5 @@ private struct PemDeviceIdentity: Codable {
var deviceId: String
var publicKeyPem: String
var privateKeyPem: String
var createdAtMs: Int
var createdAtMs: Int64
}

View file

@ -1,4 +1,4 @@
#if Talk
#if Talk && canImport(ElevenLabsKit)
@_exported import ElevenLabsKit
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice

View file

@ -564,7 +564,7 @@ public actor GatewayChannelActor {
} else if let password = selectedAuth.authPassword {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let signedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge()
if includeDeviceIdentity, let identity {
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(

View file

@ -1,7 +1,9 @@
import Foundation
#if canImport(UIKit)
#if os(iOS)
import UIKit
#elseif os(watchOS)
import WatchKit
#endif
public enum InstanceIdentity {
@ -12,7 +14,7 @@ public enum InstanceIdentity {
UserDefaults(suiteName: suiteName) ?? .standard
}
#if canImport(UIKit)
#if os(iOS) || os(watchOS)
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
if Thread.isMainThread {
return MainActor.assumeIsolated { body() }
@ -38,11 +40,16 @@ public enum InstanceIdentity {
}()
public static let displayName: String = {
#if canImport(UIKit)
#if os(iOS)
let name = Self.readMainActor {
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
return name.isEmpty ? "openclaw" : name
#elseif os(watchOS)
let name = Self.readMainActor {
WKInterfaceDevice.current().name.trimmingCharacters(in: .whitespacesAndNewlines)
}
return name.isEmpty ? "Apple Watch" : name
#else
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty
@ -54,7 +61,7 @@ public enum InstanceIdentity {
}()
public static let modelIdentifier: String? = {
#if canImport(UIKit)
#if os(iOS) || os(watchOS)
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
@ -77,7 +84,7 @@ public enum InstanceIdentity {
}()
public static let deviceFamily: String = {
#if canImport(UIKit)
#if os(iOS)
return Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom {
case .pad: "iPad"
@ -85,6 +92,8 @@ public enum InstanceIdentity {
default: "iOS"
}
}
#elseif os(watchOS)
return "Apple Watch"
#else
return "Mac"
#endif
@ -92,7 +101,7 @@ public enum InstanceIdentity {
public static let platformString: String = {
let v = ProcessInfo.processInfo.operatingSystemVersion
#if canImport(UIKit)
#if os(iOS)
let name = Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom {
case .pad: "iPadOS"
@ -101,6 +110,8 @@ public enum InstanceIdentity {
}
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#elseif os(watchOS)
return "watchOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#else
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#endif

View file

@ -7,6 +7,7 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case directNodeSetup = "watch.node.setup"
case reply = "watch.reply"
case appSnapshot = "watch.app.snapshot"
case appSnapshotRequest = "watch.app.snapshotRequest"
@ -59,7 +60,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
public var host: String?
public var nodeId: String?
public var agentId: String?
public var expiresAtMs: Int?
public var expiresAtMs: Int64?
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
public var risk: OpenClawWatchRisk?
@ -71,7 +72,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
host: String? = nil,
nodeId: String? = nil,
agentId: String? = nil,
expiresAtMs: Int? = nil,
expiresAtMs: Int64? = nil,
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
risk: OpenClawWatchRisk? = nil)
{
@ -91,13 +92,13 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approval: OpenClawWatchExecApprovalItem
public var sentAtMs: Int?
public var sentAtMs: Int64?
public var deliveryId: String?
public var resetResolvingState: Bool?
public init(
approval: OpenClawWatchExecApprovalItem,
sentAtMs: Int? = nil,
sentAtMs: Int64? = nil,
deliveryId: String? = nil,
resetResolvingState: Bool? = nil)
{
@ -115,14 +116,14 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata
public var gatewayStableID: String?
public var decision: OpenClawWatchExecApprovalDecision
public var replyId: String
public var sentAtMs: Int?
public var sentAtMs: Int64?
public init(
approvalId: String,
gatewayStableID: String? = nil,
decision: OpenClawWatchExecApprovalDecision,
replyId: String,
sentAtMs: Int? = nil)
sentAtMs: Int64? = nil)
{
self.type = .execApprovalResolve
self.approvalId = approvalId
@ -138,14 +139,14 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
public var approvalId: String
public var gatewayStableID: String?
public var decision: OpenClawWatchExecApprovalDecision?
public var resolvedAtMs: Int?
public var resolvedAtMs: Int64?
public var source: String?
public init(
approvalId: String,
gatewayStableID: String? = nil,
decision: OpenClawWatchExecApprovalDecision? = nil,
resolvedAtMs: Int? = nil,
resolvedAtMs: Int64? = nil,
source: String? = nil)
{
self.type = .execApprovalResolved
@ -162,13 +163,13 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata
public var approvalId: String
public var gatewayStableID: String?
public var reason: OpenClawWatchExecApprovalCloseReason
public var expiredAtMs: Int?
public var expiredAtMs: Int64?
public init(
approvalId: String,
gatewayStableID: String? = nil,
reason: OpenClawWatchExecApprovalCloseReason,
expiredAtMs: Int? = nil)
expiredAtMs: Int64? = nil)
{
self.type = .execApprovalExpired
self.approvalId = approvalId
@ -182,13 +183,13 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat
public var type: OpenClawWatchPayloadType
public var approvals: [OpenClawWatchExecApprovalItem]
public var gatewayStableID: String?
public var sentAtMs: Int?
public var sentAtMs: Int64?
public var snapshotId: String?
public init(
approvals: [OpenClawWatchExecApprovalItem],
gatewayStableID: String? = nil,
sentAtMs: Int? = nil,
sentAtMs: Int64? = nil,
snapshotId: String? = nil)
{
self.type = .execApprovalSnapshot
@ -202,9 +203,9 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int?
public var sentAtMs: Int64?
public init(requestId: String, sentAtMs: Int? = nil) {
public init(requestId: String, sentAtMs: Int64? = nil) {
self.type = .execApprovalSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
@ -215,13 +216,13 @@ public struct OpenClawWatchChatItem: Codable, Sendable, Equatable, Identifiable
public var id: String
public var role: String
public var text: String
public var timestampMs: Int?
public var timestampMs: Int64?
public init(
id: String,
role: String,
text: String,
timestampMs: Int? = nil)
timestampMs: Int64? = nil)
{
self.id = id
self.role = role
@ -234,9 +235,9 @@ public struct OpenClawWatchChatCompletionMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var commandId: String
public var replyText: String
public var sentAtMs: Int?
public var sentAtMs: Int64?
public init(commandId: String, replyText: String, sentAtMs: Int? = nil) {
public init(commandId: String, replyText: String, sentAtMs: Int64? = nil) {
self.type = .chatCompletion
self.commandId = commandId
self.replyText = replyText
@ -260,7 +261,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
public var pendingApprovalCount: Int
public var chatItems: [OpenClawWatchChatItem]?
public var chatStatusText: String?
public var sentAtMs: Int?
public var sentAtMs: Int64?
public var snapshotId: String?
public init(
@ -278,7 +279,7 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
pendingApprovalCount: Int,
chatItems: [OpenClawWatchChatItem]? = nil,
chatStatusText: String? = nil,
sentAtMs: Int? = nil,
sentAtMs: Int64? = nil,
snapshotId: String? = nil)
{
self.type = .appSnapshot
@ -304,9 +305,9 @@ public struct OpenClawWatchAppSnapshotMessage: Codable, Sendable, Equatable {
public struct OpenClawWatchAppSnapshotRequestMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int?
public var sentAtMs: Int64?
public init(requestId: String, sentAtMs: Int? = nil) {
public init(requestId: String, sentAtMs: Int64? = nil) {
self.type = .appSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
@ -328,7 +329,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable {
public var sessionKey: String?
public var gatewayStableID: String?
public var text: String?
public var sentAtMs: Int?
public var sentAtMs: Int64?
public init(
command: OpenClawWatchAppCommand,
@ -336,7 +337,7 @@ public struct OpenClawWatchAppCommandMessage: Codable, Sendable, Equatable {
sessionKey: String? = nil,
gatewayStableID: String? = nil,
text: String? = nil,
sentAtMs: Int? = nil)
sentAtMs: Int64? = nil)
{
self.type = .appCommand
self.command = command
@ -379,7 +380,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
public var gatewayStableID: String?
public var kind: String?
public var details: String?
public var expiresAtMs: Int?
public var expiresAtMs: Int64?
public var risk: OpenClawWatchRisk?
public var actions: [OpenClawWatchAction]?
@ -392,7 +393,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
gatewayStableID: String? = nil,
kind: String? = nil,
details: String? = nil,
expiresAtMs: Int? = nil,
expiresAtMs: Int64? = nil,
risk: OpenClawWatchRisk? = nil,
actions: [OpenClawWatchAction]? = nil)
{

View file

@ -1,4 +1,5 @@
import Foundation
#if canImport(WebKit)
import WebKit
public enum WebViewJavaScriptSupport {
@ -55,3 +56,4 @@ public enum WebViewJavaScriptSupport {
return "null"
}
}
#endif

View file

@ -14,6 +14,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
let container = try decoder.singleValueContainer()
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let int64Val = try? container.decode(Int64.self) { self.value = int64Val; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
@ -27,6 +28,7 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
switch self.value {
case let boolVal as Bool: try container.encode(boolVal)
case let intVal as Int: try container.encode(intVal)
case let int64Val as Int64: try container.encode(int64Val)
case let doubleVal as Double: try container.encode(doubleVal)
case let stringVal as String: try container.encode(stringVal)
case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID():
@ -66,6 +68,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
switch (lhs.value, rhs.value) {
case let (l as Bool, r as Bool): l == r
case let (l as Int, r as Int): l == r
case let (l as Int64, r as Int64): l == r
case let (l as Int, r as Int64): Int64(l) == r
case let (l as Int64, r as Int): l == Int64(r)
case let (l as Double, r as Double): l == r
case let (l as String, r as String): l == r
case (_ as NSNull, _ as NSNull): true
@ -81,6 +86,8 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
case let v as Bool:
hasher.combine(2); hasher.combine(v)
case let v as Int:
hasher.combine(0); hasher.combine(Int64(v))
case let v as Int64:
hasher.combine(0); hasher.combine(v)
case let v as Double:
hasher.combine(1); hasher.combine(v)

View file

@ -8679,21 +8679,25 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
public let publicurl: String?
public let preferremoteurl: Bool?
public let includeqr: Bool?
public let bootstrapprofile: String?
public init(
publicurl: String?,
preferremoteurl: Bool?,
includeqr: Bool?)
includeqr: Bool?,
bootstrapprofile: String?)
{
self.publicurl = publicurl
self.preferremoteurl = preferremoteurl
self.includeqr = includeqr
self.bootstrapprofile = bootstrapprofile
}
private enum CodingKeys: String, CodingKey {
case publicurl = "publicUrl"
case preferremoteurl = "preferRemoteUrl"
case includeqr = "includeQr"
case bootstrapprofile = "bootstrapProfile"
}
}

View file

@ -3,6 +3,22 @@ import Testing
import OpenClawProtocol
struct AnyCodableTests {
@Test
func roundTripsEpochMillisecondsBeyondInt32() throws {
let epochMilliseconds: Int64 = 1_800_000_000_000
let original = AnyCodable(epochMilliseconds)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(AnyCodable.self, from: data)
let decodedMilliseconds = (decoded.value as? Int64)
?? (decoded.value as? Int).map(Int64.init)
#expect(String(decoding: data, as: UTF8.self) == "1800000000000")
#expect(decodedMilliseconds == epochMilliseconds)
#expect(decoded == original)
#expect(Set([decoded, original]).count == 1)
}
@Test
func encodesNSNumberBooleansAsJSONBooleans() throws {
let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true)))

View file

@ -1,3 +1,5 @@
import CryptoKit
import Foundation
import Testing
@testable import OpenClawKit
@ -5,29 +7,31 @@ import Testing
struct DeviceAuthPayloadTests {
@Test
func `builds Swift connect compatibility payload with v2 canonical fields`() {
let signedAtMs: Int64 = 1_800_000_000_000
let payload = GatewayDeviceAuthPayload.buildConnectCompatibilityPayload(
deviceId: "dev-1",
clientId: "openclaw-macos",
clientMode: "ui",
role: "operator",
scopes: ["operator.admin", "operator.read"],
signedAtMs: 1_700_000_000_000,
signedAtMs: signedAtMs,
token: "tok-123",
nonce: "nonce-abc")
#expect(
payload
== "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc")
== "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc")
}
@Test
func `builds canonical v3 payload vector`() {
let signedAtMs: Int64 = 1_800_000_000_000
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: "dev-1",
clientId: "openclaw-macos",
clientMode: "ui",
role: "operator",
scopes: ["operator.admin", "operator.read"],
signedAtMs: 1_700_000_000_000,
signedAtMs: signedAtMs,
token: "tok-123",
nonce: "nonce-abc",
platform: " IOS ",
@ -35,7 +39,47 @@ struct DeviceAuthPayloadTests {
#expect(
payload
==
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1800000000000|tok-123|nonce-abc|ios|iphone")
}
@Test
func `signed device dictionary preserves 64-bit timestamp`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false)
defer { try? FileManager.default.removeItem(at: tempDir) }
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
let signedAtMs: Int64 = 1_800_000_000_000
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: "openclaw-watchos",
clientMode: "node",
role: "node",
scopes: [],
signedAtMs: signedAtMs,
token: "device-token",
nonce: "nonce-abc",
platform: "watchOS",
deviceFamily: "Apple Watch")
let device = try #require(GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: "nonce-abc"))
let signature = try #require(device["signature"]?.value as? String)
let signatureBase64 = signature
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let signaturePadding = String(repeating: "=", count: (4 - signatureBase64.count % 4) % 4)
let signatureData = try #require(Data(base64Encoded: signatureBase64 + signaturePadding))
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
let data = try JSONEncoder().encode(device)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(publicKey.isValidSignature(signatureData, for: Data(payload.utf8)))
#expect((object["signedAt"] as? NSNumber)?.int64Value == signedAtMs)
}
@Test

View file

@ -25,12 +25,49 @@ struct DeviceIdentityStoreTests {
deviceId: "unwritable-device",
role: "node",
token: "must-not-be-acknowledged")
let publicWritePersisted = DeviceAuthStore.storeTokenPersisted(
deviceId: "unwritable-device",
role: "node",
token: "also-must-not-be-acknowledged")
let durableIdentity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary)
#expect(compatibleEntry.token == "must-not-be-acknowledged")
#expect(!stored.persisted)
#expect(!publicWritePersisted)
#expect(durableIdentity == nil)
#expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil)
}
@Test
func `device auth entry round-trips epoch milliseconds beyond Int32`() throws {
let epochMilliseconds: Int64 = 1_800_000_000_000
let entry = DeviceAuthEntry(
token: "device-token",
role: "node",
scopes: [],
updatedAtMs: epochMilliseconds)
let data = try JSONEncoder().encode(entry)
let decoded = try JSONDecoder().decode(DeviceAuthEntry.self, from: data)
#expect(decoded.updatedAtMs == epochMilliseconds)
}
@Test
func `durable identity creation verifies persisted key material`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir.appendingPathComponent("device.json", isDirectory: false)
defer { try? FileManager.default.removeItem(at: tempDir) }
let identity = try #require(DeviceIdentityStore.loadOrCreatePersisted(fileURL: identityURL))
let reloaded = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
#expect(reloaded.deviceId == identity.deviceId)
#expect(reloaded.publicKey == identity.publicKey)
#expect(reloaded.privateKey == identity.privateKey)
}
@Test(.stateDirectoryIsolated)
func `device auth tokens are isolated by gateway owner`() {
let deviceID = "test-device"
@ -262,6 +299,7 @@ struct DeviceIdentityStoreTests {
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
#expect(identity.createdAtMs == 1_800_000_000_000)
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
@ -500,7 +538,7 @@ struct DeviceIdentityStoreTests {
"deviceId": "stale-device-id",
"publicKeyPem": publicKeyPem,
"privateKeyPem": privateKeyPem,
"createdAtMs": 1_700_000_000_000,
"createdAtMs": Int64(1_800_000_000_000),
]
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
return String(decoding: data, as: UTF8.self) + "\n"

View file

@ -1575,6 +1575,20 @@ struct GatewayNodeSessionTests {
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func `watch invoke payload decodes without bridge frame type`() throws {
let data = Data(
#"{"id":"invoke-1","nodeId":"watch-1","command":"device.info","paramsJSON":null,"timeoutMs":2000}"#
.utf8)
let request = try JSONDecoder().decode(NodeInvokeRequestEvent.self, from: data)
#expect(request.id == "invoke-1")
#expect(request.nodeid == "watch-1")
#expect(request.command == "device.info")
#expect(request.paramsjson == nil)
#expect(request.timeoutms == 2000)
}
@Test
func `invoke with timeout returns underlying response before timeout`() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)

View file

@ -71,7 +71,7 @@ openclaw nodes location get --node <id> --accuracy precise
openclaw nodes screen record --node <id> --duration 10s --fps 10 --out ./clip.mp4
```
- `notify` sends a local notification on a node (macOS only). Requires `--title` or `--body`. Options: `--sound <name>`, `--priority <passive|active|timeSensitive>`, `--delivery <system|overlay|auto>` (default `system`), `--invoke-timeout <ms>` (default `15000`).
- `notify` sends a local notification on a node that declares `system.notify`, including macOS, iOS, Android, and direct watchOS nodes. Direct watchOS delivery requires OpenClaw to be active. Requires `--title` or `--body`. Options: `--sound <name>`, `--priority <passive|active|timeSensitive>`, `--delivery <system|overlay|auto>` (default `system`), `--invoke-timeout <ms>` (default `15000`).
- `push` sends an APNs test push to an iOS node. Options: `--title <text>` (default `OpenClaw`), `--body <text>`, `--environment <sandbox|production>` to override the detected APNs environment.
- `location get` fetches the node's current location. Options: `--max-age <ms>` (reuse a cached fix), `--accuracy <coarse|balanced|precise>`, `--location-timeout <ms>` (default `10000`), `--invoke-timeout <ms>` (default `20000`).
- `screen record` captures a short clip and prints the saved path (or writes JSON with `--json`). Options: `--screen <index>` (default `0`), `--duration <ms|10s>` (default `10000`), `--fps <fps>` (default `10`), `--no-audio`, `--out <path>`, `--invoke-timeout <ms>` (default `120000`).

View file

@ -4943,6 +4943,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: What it does
- H2: Requirements
- H2: Quick start (pair + connect)
- H2: Optional direct Apple Watch node
- H2: Relay-backed push for official builds
- H2: Background alive beacons
- H2: Authentication and trust flow

View file

@ -1,13 +1,13 @@
---
summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/device/notifications/system"
read_when:
- Pairing iOS/Android nodes to a gateway
- Pairing iOS/watchOS/Android nodes to a gateway
- Using node canvas/camera for agent context
- Adding new node commands or CLI helpers
title: "Nodes"
---
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
A **node** is a companion device (macOS/iOS/watchOS/Android/headless) that connects to the Gateway with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Most nodes use the Gateway WebSocket on the operator port. The optional direct Apple Watch node uses signed HTTPS polling on that same port because watchOS blocks generic low-level networking for ordinary apps. Protocol details: [Gateway protocol](/gateway/protocol).
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; historical only for current nodes).
@ -19,7 +19,7 @@ Troubleshooting runbook: [/nodes/troubleshooting](/nodes/troubleshooting)
## Pairing + status
WS nodes use **device pairing**. A node presents a device identity during `connect`; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Nodes use **device pairing**. A node presents a signed device identity during connect; the Gateway creates a device pairing request for `role: node`. Approve via the devices CLI (or UI). The direct Apple Watch setup uses an admin-minted, short-lived node-only setup code to approve its fixed low-risk command surface; later capability expansion still requires normal approval.
```bash
openclaw devices list
@ -33,7 +33,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
- The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a role that pairing approval never granted.
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate the WS `connect` handshake — device pairing does that.
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate, gateway-owned node pairing store that tracks the node's approved command/capability surface across reconnects. It does **not** gate transport authentication — device pairing does that.
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a device-backed node it revokes the device's `node` role in `devices/paired.json` and disconnects that device's node-role sessions: a mixed-role device keeps its row and only loses the `node` role, while a node-only device row is deleted. It also clears any matching entry from the separate node pairing store. `operator.pairing` may remove non-operator node rows on other devices; a device-token caller revoking its own node role on a mixed-role device additionally needs `operator.admin`.
- Approval scope follows the pending request's declared commands:
- commandless request: `operator.pairing`
@ -42,7 +42,7 @@ Pending pairing requests expire 5 minutes after the device's last retry — a de
## Version skew and upgrade order
The Gateway accepts authenticated node clients across an N-1 protocol window.
The Gateway WebSocket accepts authenticated node clients across an N-1 protocol window.
The current v4 Gateway therefore accepts v3 nodes when the connection declares
both `role: "node"` and `client.mode: "node"`. Operator and UI sessions must
still use the current protocol.
@ -55,6 +55,9 @@ Plugin-owned capabilities and commands stay hidden until the node upgrades to
the current protocol. Nodes older than N-1 require an out-of-band upgrade before
reconnecting.
The direct watchOS HTTPS transport requires the current protocol version; update
the watch app with the Gateway before enabling direct mode.
## Remote node host (system.run)
Use a **node host** when your Gateway runs on one machine and you want commands to execute on another. The model still talks to the **gateway**; the gateway forwards `exec` calls to the **node host** when `host=node` is selected.
@ -203,7 +206,7 @@ openclaw nodes invoke --node <idOrNameOrIp> --command canvas.eval --params '{"ja
Node commands must pass two gates before they can be invoked:
1. The node must declare the command in its WebSocket `connect.commands` list.
1. The node must declare the command in its authenticated connect metadata (`connect.commands`).
2. The gateway's platform-and-approval-derived allowlist must include the declared command.
Default allowlists by platform (before plugin defaults and `allowCommands`/`denyCommands` overrides):
@ -211,6 +214,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny
| Platform | Commands allowed by default |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
| watchOS | `device.info`, `device.status`, `system.notify` |
| Android | `camera.list`, `location.get`, `notifications.list`, `notifications.actions`, `system.notify`, `device.info`, `device.status`, `device.permissions`, `device.health`, `device.apps`, `contacts.search`, `calendar.events`, `callLog.search`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer` |
| macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` |
| Windows | `camera.list`, `location.get`, `device.info`, `device.status`, `system.notify` |

View file

@ -2,6 +2,7 @@
summary: "iOS node app: connect to the Gateway, pairing, canvas, and troubleshooting"
read_when:
- Pairing or reconnecting the iOS node
- Enabling or troubleshooting the direct Apple Watch node
- Running the iOS app from source
- Debugging gateway discovery or canvas commands
title: "iOS app"
@ -53,12 +54,6 @@ creation has a token or password auth path.
4. The official app connects automatically. If **Pending approval** shows a
request, review its role and scopes before approving it.
The Apple Watch companion does not have a separate OpenClaw pairing approval.
Pair the Watch with the iPhone in Apple's Watch app, install OpenClaw from
**Watch app -> My Watch -> Available Apps**, then open OpenClaw once on both
devices. OpenClaw follows Apple Watch pairing and install changes immediately;
the Gateway's device approval covers the iPhone node.
The Control UI button requires an already paired session with `operator.admin`.
As a terminal fallback, pick a discovered gateway in the iOS app (or enable
Manual Host and enter host/port), then approve the request on the Gateway host:
@ -93,6 +88,55 @@ openclaw nodes status
openclaw gateway call node.list --params "{}"
```
By default, the Apple Watch companion keeps using the existing iPhone relay and
does not need a separate Gateway pairing. Pair the Watch with the iPhone in
Apple's Watch app, install OpenClaw from **Watch app -> My Watch -> Available
Apps**, then open OpenClaw once on both devices.
## Optional direct Apple Watch node
Direct mode gives the watch its own signed node identity and Gateway connection.
Supported node commands continue to work over watch Wi-Fi or cellular while
OpenClaw is active, even when the paired iPhone is unavailable.
Requirements:
- The iPhone is connected to the Gateway with `operator.admin` scope.
- The setup code advertises a `wss://` Gateway endpoint with a certificate trusted
by watchOS; the watch polls the corresponding `https://` origin. Plain HTTP and
self-signed or fingerprint-only trust are unsupported. See [Gateway-owned
pairing](/gateway/pairing) for endpoint configuration. Loopback, iPhone-only,
and tailnet-only routes are not independently reachable by the watch.
- Cellular use requires a cellular-capable Apple Watch with active service.
- OpenClaw is active on the watch. Apple does not allow ordinary watchOS apps to
keep generic WebSocket/TCP connections, so the direct node uses short HTTPS
polls and reconnects when the app returns to the foreground. See Apple's
[watchOS low-level networking guidance](https://developer.apple.com/documentation/technotes/tn3135-low-level-networking-on-watchOS).
Setup:
1. On iPhone, open **Settings -> Apple Watch**.
2. Tap **Enable Direct Gateway Connection**.
3. Open OpenClaw on the watch before the short-lived setup code expires.
4. Verify the separate Apple Watch row with `openclaw nodes status`.
The setup code contains a short-lived, node-only bootstrap credential; treat it
like a password until it expires. It never contains the iPhone's saved Gateway
password or token. After pairing, the watch stores its own device token and
deletes the bootstrap credential. Direct mode covers only the commands below.
Chat, Talk, approvals, and the existing `watch.*` notification flow remain
iPhone-relay features and still require the paired iPhone.
Direct watchOS node commands:
| Surface | Commands | Notes |
| ------------- | ------------------------------ | ------------------------------------------------------- |
| Device | `device.info`, `device.status` | Watch identity, battery, thermal, storage, and network. |
| Notifications | `system.notify` | While the app is active; requires watch permission. |
watchOS does not expose WebKit to third-party apps, so the direct watch node
does not advertise Canvas commands.
## Relay-backed push for official builds
Official distributed iOS builds use an external push relay instead of publishing the raw APNs token to the gateway. Official App Store builds from the public release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`; this base URL is hardcoded for App Store distribution and does not read any override.

View file

@ -22,6 +22,7 @@ export const GATEWAY_CLIENT_IDS = {
GATEWAY_CLIENT: "gateway-client",
MACOS_APP: "openclaw-macos",
IOS_APP: "openclaw-ios",
WATCHOS_APP: "openclaw-watchos",
ANDROID_APP: "openclaw-android",
NODE_HOST: "node-host",
TEST: "test",

View file

@ -92,13 +92,15 @@ const SetupCodeQrDataUrlSchema = Type.String({
* a short-lived bootstrap token that hands off broad operator scopes
* (read/write/approvals/talk.secrets), so this method requires operator.admin
* (enforced by the core method descriptor's method-scope policy, not the handler)
* and is not advertised.
* and is not advertised. `bootstrapProfile: "node"` narrows the handoff to a
* node role with no operator scopes for companion devices such as watchOS.
*/
export const DevicePairSetupCodeParamsSchema = Type.Object(
{
publicUrl: Type.Optional(NonEmptyString),
preferRemoteUrl: Type.Optional(Type.Boolean()),
includeQr: Type.Optional(Type.Boolean()),
bootstrapProfile: Type.Optional(Type.Literal("node")),
},
{ additionalProperties: false },
);

View file

@ -17,7 +17,7 @@ export function registerNodesNotifyCommand(nodes: Command) {
nodesCallOpts(
nodes
.command("notify")
.description("Send a local notification on a node (mac only)")
.description("Send a local notification on a node")
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
.option("--title <text>", "Notification title")
.option("--body <text>", "Notification body")

View file

@ -55,6 +55,9 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval";
// device signature can queue the bootstrap-pairing flow behind their
// requests, blocking legitimate node onboarding during the attack.
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
// Public watchOS challenge issuance is throttled separately from credential
// failures so challenge floods cannot displace legitimate device handshakes.
export const AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE = "watch-challenge";
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:";

View file

@ -246,6 +246,42 @@ describe("gateway/node-command-policy", () => {
expect(macAllowlist.has("system.run")).toBe(false);
expect(macAllowlist.has("system.which")).toBe(false);
expect(macAllowlist.has("screen.snapshot")).toBe(false);
const watchAllowlist = resolveNodeCommandAllowlist(cfg, {
platform: "watchOS 11.5.0",
deviceFamily: "Apple Watch",
});
expect(watchAllowlist.has("device.info")).toBe(true);
expect(watchAllowlist.has("device.status")).toBe(true);
expect(watchAllowlist.has("system.notify")).toBe(true);
expect(watchAllowlist.has("camera.list")).toBe(false);
expect(watchAllowlist.has("system.run")).toBe(false);
});
it("requires matching watchOS platform and device-family metadata", () => {
const cfg = {} as OpenClawConfig;
const mismatch = resolveNodeCommandAllowlist(cfg, {
platform: "watchOS 11.5.0",
deviceFamily: "iPhone",
});
expect(mismatch.has("device.info")).toBe(false);
const familyOnly = resolveNodeCommandAllowlist(cfg, { deviceFamily: "Apple Watch" });
expect(familyOnly.has("device.info")).toBe(true);
expect(familyOnly.has("system.run")).toBe(false);
});
it("keeps plugin defaults out of the fixed watchOS command surface", () => {
installCanvasPluginDefaults();
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
platform: "watchOS 11.5.0",
deviceFamily: "Apple Watch",
});
expect(allowlist.has("device.info")).toBe(true);
expect(allowlist.has("canvas.snapshot")).toBe(false);
expect(allowlist.has("canvas.present")).toBe(false);
});
it("keeps explicitly approved host commands for desktop platforms", () => {

View file

@ -99,6 +99,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...MOTION_COMMANDS,
...IOS_SYSTEM_COMMANDS,
],
watchos: [...DEVICE_COMMANDS, ...IOS_SYSTEM_COMMANDS],
android: [
...CAMERA_COMMANDS,
...LOCATION_COMMANDS,
@ -140,10 +141,11 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
unknown: [...UNKNOWN_PLATFORM_COMMANDS],
};
type PlatformId = "ios" | "android" | "macos" | "windows" | "linux" | "unknown";
type PlatformId = "ios" | "watchos" | "android" | "macos" | "windows" | "linux" | "unknown";
const CANONICAL_PLATFORM_IDS = new Set<Exclude<PlatformId, "unknown">>([
"ios",
"watchos",
"android",
"macos",
"windows",
@ -155,6 +157,7 @@ const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{
tokens: readonly string[];
}> = [
{ id: "ios", tokens: ["iphone", "ipad", "ios"] },
{ id: "watchos", tokens: ["apple watch", "watchos"] },
{ id: "android", tokens: ["android"] },
{ id: "macos", tokens: ["mac"] },
{ id: "windows", tokens: ["windows"] },
@ -175,6 +178,8 @@ function platformMatchesDeviceFamily(
switch (platformId) {
case "ios":
return family === "" || /^(?:iphone|ipad|ios)$/.test(family);
case "watchos":
return family === "apple watch" || family === "watchos";
case "android":
return family === "" || family === "android";
case "macos":
@ -194,6 +199,9 @@ function resolvePlatformIdByNativeLabel(
if (/^(?:ios|ipados) \d+(?:\.\d+){0,2}$/.test(platform)) {
return /^(?:iphone|ipad|ios)$/.test(deviceFamily) ? "ios" : undefined;
}
if (/^watchos \d+(?:\.\d+){0,2}$/.test(platform)) {
return /^(?:apple watch|watchos)$/.test(deviceFamily) ? "watchos" : undefined;
}
if (/^macos \d+(?:\.\d+){0,2}$/.test(platform)) {
return deviceFamily === "mac" ? "macos" : undefined;
}
@ -249,6 +257,11 @@ export function listDangerousPluginNodeCommands(): string[] {
}
function listDefaultPluginNodeCommands(platformId: PlatformId): string[] {
// The direct watch transport has a fixed, minimal command surface. Do not let
// generic plugin defaults silently expand it when plugins are installed.
if (platformId === "watchos") {
return [];
}
const registry = getActivePluginGatewayNodePolicyRegistry();
if (!registry) {
return [];

View file

@ -72,7 +72,7 @@ type NodeInvokeResult = {
};
/** Connectivity probe result for a registered node. */
type NodeConnectivityResult =
export type NodeConnectivityResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } };
@ -98,6 +98,13 @@ export type SerializedEventPayload = {
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
};
/** Event transport for nodes that cannot keep a WebSocket open, such as watchOS. */
export type NodeEventTransport = {
send: (event: string, payload: unknown) => boolean;
sendRaw: (event: string, payloadJSON?: SerializedEventPayload | null) => boolean;
checkConnectivity?: (timeoutMs: number) => Promise<NodeConnectivityResult>;
};
/** Serialize an event payload once so fanout can reuse the same JSON string. */
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
if (payload === undefined) {
@ -177,11 +184,29 @@ function withSystemRunEventRunId(params: { command: string; params?: unknown }):
export class NodeRegistry {
private nodesById = new Map<string, NodeSession>();
private nodesByConn = new Map<string, string>();
private eventTransportsByConn = new Map<string, NodeEventTransport>();
private pendingInvokes = new Map<string, PendingInvoke>();
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
/** Register a websocket client as the current connection for its node id. */
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
return this.registerSession(client, opts);
}
/** Register a node whose events are delivered by an HTTP polling transport. */
registerTransport(
client: GatewayWsClient,
opts: { remoteIp?: string | undefined },
transport: NodeEventTransport,
) {
return this.registerSession(client, opts, transport);
}
private registerSession(
client: GatewayWsClient,
opts: { remoteIp?: string | undefined },
transport?: NodeEventTransport,
) {
const connect = client.connect;
const nodeId = connect.device?.id ?? connect.client.id;
const caps = Array.isArray(connect.caps) ? connect.caps : [];
@ -249,6 +274,11 @@ export class NodeRegistry {
};
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
if (transport) {
this.eventTransportsByConn.set(client.connId, transport);
} else {
this.eventTransportsByConn.delete(client.connId);
}
return session;
}
@ -259,6 +289,7 @@ export class NodeRegistry {
return null;
}
this.nodesByConn.delete(connId);
this.eventTransportsByConn.delete(connId);
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
if (unregistersCurrentNode) {
this.nodesById.delete(nodeId);
@ -298,6 +329,10 @@ export class NodeRegistry {
error: { code: "NOT_CONNECTED", message: "node not connected" },
};
}
const eventTransport = this.eventTransportsByConn.get(node.connId);
if (eventTransport) {
return eventTransport.checkConnectivity?.(timeoutMs) ?? { ok: true };
}
const socket = node.client.socket as PingableSocket;
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) {
return {
@ -707,6 +742,10 @@ export class NodeRegistry {
}
private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean {
const eventTransport = this.eventTransportsByConn.get(node.connId);
if (eventTransport) {
return eventTransport.send(event, payload);
}
if (this.rejectSlowNodeSocket(node)) {
return false;
}
@ -736,6 +775,10 @@ export class NodeRegistry {
) {
return false;
}
const eventTransport = this.eventTransportsByConn.get(node.connId);
if (eventTransport) {
return eventTransport.sendRaw(event, payloadJSON);
}
if (this.rejectSlowNodeSocket(node)) {
return false;
}

View file

@ -54,6 +54,8 @@ type PluginHttpRequestHandler = (
},
) => Promise<boolean>;
type WatchNodeHttpRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
type PluginHttpUpgradeHandler = (
req: IncomingMessage,
socket: import("node:stream").Duplex,
@ -442,6 +444,7 @@ export function createGatewayHttpServer(opts: {
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
handleHooksRequest: HooksRequestHandler;
handleWatchNodeRequest?: WatchNodeHttpRequestHandler;
handlePluginRequest?: PluginHttpRequestHandler;
handlePluginUpgrade?: PluginHttpUpgradeHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
@ -556,6 +559,12 @@ export function createGatewayHttpServer(opts: {
run: () => handleHooksRequest(req, res),
},
];
if (opts.handleWatchNodeRequest && scopedRequestPath.startsWith("/api/nodes/watch/")) {
requestStages.push({
name: "watch-node",
run: () => opts.handleWatchNodeRequest?.(req, res) ?? false,
});
}
if (openAiCompatEnabled && isOpenAiModelsPath(scopedRequestPath)) {
requestStages.push({
name: "models",

View file

@ -164,6 +164,21 @@ describe("device.pair.setupCode", () => {
expect(payload.setupCode).toBe("SETUP-CODE-XYZ");
});
it("requests a node-only bootstrap profile for companion setup", async () => {
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
const { options } = createOptions({ includeQr: false, bootstrapProfile: "node" });
await devicePairSetupHandlers["device.pair.setupCode"](options);
expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
bootstrapProfile: { roles: ["node"], scopes: [] },
}),
);
});
it("omits an oversized QR but still returns the setup code", async () => {
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");

View file

@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { renderQrPngDataUrl } from "../../media/qr-image.js";
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../../shared/device-bootstrap-profile.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
@ -49,6 +50,9 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
env: process.env,
publicUrl,
preferRemoteUrl: params.preferRemoteUrl === true,
...(params.bootstrapProfile === "node"
? { bootstrapProfile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }
: {}),
// Lets Tailscale serve/funnel URLs resolve, mirroring the `openclaw qr` CLI.
runCommandWithTimeout: async (argv, runOpts) =>
await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs }),

View file

@ -152,8 +152,11 @@ describe("createGatewayRequestContext", () => {
socket: { close: vi.fn() },
};
const clients = new Set([target, unrelated]) as never;
const invalidateDeviceTransports = vi.fn();
const context = createGatewayRequestContext(makeContextParams({ clients }));
const context = createGatewayRequestContext(
makeContextParams({ clients, invalidateDeviceTransports }),
);
context.invalidateClientsForDevice?.("device-1", { reason: "device-token-rotated" });
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
@ -164,6 +167,9 @@ describe("createGatewayRequestContext", () => {
expect((unrelated as { invalidated?: boolean }).invalidated).toBeUndefined();
expect(unrelated.socket.close).not.toHaveBeenCalled();
expect(invalidateDeviceTransports).toHaveBeenCalledWith("device-1", {
reason: "device-token-rotated",
});
});
it("disconnectClientsForDevice also marks the invalidated flag before closing", () => {
@ -173,13 +179,17 @@ describe("createGatewayRequestContext", () => {
socket: { close: vi.fn() },
};
const clients = new Set([target]) as never;
const disconnectDeviceTransports = vi.fn();
const context = createGatewayRequestContext(makeContextParams({ clients }));
const context = createGatewayRequestContext(
makeContextParams({ clients, disconnectDeviceTransports }),
);
context.disconnectClientsForDevice?.("device-1");
expect((target as { invalidated?: boolean }).invalidated).toBe(true);
expect((target as { invalidatedReason?: string }).invalidatedReason).toBe("device-removed");
expect(target.socket.close).toHaveBeenCalledWith(4001, "device removed");
expect(disconnectDeviceTransports).toHaveBeenCalledWith("device-1", undefined);
});
it("invalidateClientsForDevice filters by role when provided", () => {

View file

@ -36,6 +36,11 @@ export type GatewayRequestContextParams = {
nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"];
hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"];
clients: Set<GatewayRequestContextClient>;
invalidateDeviceTransports?: (
deviceId: string,
opts?: { role?: string; reason?: string },
) => void;
disconnectDeviceTransports?: (deviceId: string, opts?: { role?: string }) => void;
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void;
nodeRegistry: GatewayRequestContext["nodeRegistry"];
terminalSessions?: GatewayRequestContext["terminalSessions"];
@ -165,6 +170,7 @@ export function createGatewayRequestContext(
gatewayClient.invalidated = true;
gatewayClient.invalidatedReason = reason;
}
params.invalidateDeviceTransports?.(deviceId, opts);
},
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
for (const gatewayClient of params.clients) {
@ -185,6 +191,7 @@ export function createGatewayRequestContext(
/* ignore */
}
}
params.disconnectDeviceTransports?.(deviceId, opts);
},
disconnectClientsUsingSharedGatewayAuth: () => {
disconnectAllSharedGatewayAuthClients(params.clients);

View file

@ -101,6 +101,7 @@ export async function createGatewayRuntimeState(params: {
logPlugins: ReturnType<typeof createSubsystemLogger>;
getReadiness?: ReadinessChecker;
isTerminalEnabled: () => boolean;
handleWatchNodeRequest?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
}): Promise<{
releasePluginRouteRegistry: () => void;
httpServer: HttpServer;
@ -261,6 +262,7 @@ export async function createGatewayRuntimeState(params: {
openResponsesEnabled: params.openResponsesEnabled,
openResponsesConfig: params.openResponsesConfig,
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
handleWatchNodeRequest: params.handleWatchNodeRequest,
handleHooksRequest,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,

View file

@ -1,3 +1,4 @@
import type { IncomingMessage, ServerResponse } from "node:http";
// Gateway server implementation builds runtime state, method registries, HTTP
// and WebSocket surfaces, config reload hooks, and graceful restart/shutdown.
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
@ -40,6 +41,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js";
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { upsertPresence } from "../infra/system-presence.js";
import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js";
import { withDiagnosticPhase } from "../logging/diagnostic-phase.js";
import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js";
@ -62,6 +64,7 @@ import {
} from "../secrets/runtime-state.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { createLazyPromise } from "../shared/lazy-runtime.js";
import { recordRemoteNodeInfo, removeRemoteNodeInfo } from "../skills/runtime/remote.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { resolveGatewayAuth } from "./auth.js";
import type { RestartRecoveryCandidate } from "./chat-abort.js";
@ -99,6 +102,7 @@ import { createLazyGatewayCronState } from "./server-cron-lazy.js";
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js";
import { GATEWAY_EVENTS } from "./server-methods-list.js";
import { clearNodeWakeState } from "./server-methods/nodes-wake-state.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js";
import { setFallbackGatewayContextResolver } from "./server-plugins.js";
import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
@ -119,6 +123,7 @@ import {
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js";
import { broadcastPresenceSnapshot } from "./server/presence-events.js";
import { createReadinessChecker } from "./server/readiness.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
@ -880,6 +885,9 @@ export async function startGatewayServer(
});
log.info("starting HTTP server...");
let currentPluginRegistryGatewayContext: GatewayRequestContext | undefined;
const watchNodeRequestHandler: {
current?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
} = {};
const {
releasePluginRouteRegistry,
httpServer,
@ -931,6 +939,8 @@ export async function startGatewayServer(
logHooks,
logPlugins,
getReadiness,
handleWatchNodeRequest: async (req, res) =>
(await watchNodeRequestHandler.current?.(req, res)) ?? false,
}),
);
const restartRecoveryCandidates = new Map<string, RestartRecoveryCandidate>();
@ -948,6 +958,48 @@ export async function startGatewayServer(
broadcastVoiceWakeChanged,
hasTalkNodeConnected,
} = createGatewayNodeSessionRuntime({ broadcast });
const { createWatchNodeHttpRuntime } = await import("./watch-node-http.js");
const watchNodeHttpRuntime = createWatchNodeHttpRuntime({
nodeRegistry,
getConfig: getRuntimeConfig,
broadcast,
rateLimiter: authRateLimiter,
nodeReapprovalCoordinator,
onNodeConnected: (session) => {
upsertPresence(session.nodeId, {
host: session.displayName ?? session.clientId ?? session.nodeId,
ip: session.remoteIp,
version: session.version,
platform: session.platform,
deviceFamily: session.deviceFamily,
modelIdentifier: session.modelIdentifier,
mode: session.clientMode,
deviceId: session.nodeId,
roles: ["node"],
scopes: [],
instanceId: session.nodeId,
reason: "connect",
});
incrementPresenceVersion();
recordRemoteNodeInfo({
nodeId: session.nodeId,
displayName: session.displayName,
platform: session.platform,
deviceFamily: session.deviceFamily,
commands: session.commands,
remoteIp: session.remoteIp,
});
},
onNodeDisconnected: (nodeId) => {
upsertPresence(nodeId, { reason: "disconnect" });
broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion });
removeRemoteNodeInfo(nodeId);
nodeUnsubscribeAll(nodeId);
clearNodeWakeState(nodeId);
},
onError: (message, error) => log.warn(`${message}: ${String(error)}`),
});
watchNodeRequestHandler.current = watchNodeHttpRuntime.handleRequest;
const { TerminalSessionManager, DEFAULT_TERMINAL_DETACH_SECONDS } =
await import("./terminal/session-manager.js");
// One PTY store per gateway. Emits each session's bytes only to the owning
@ -994,6 +1046,7 @@ export async function startGatewayServer(
};
const runClosePrelude = async () => {
markClosePreludeStarted();
watchNodeHttpRuntime.close();
clearPluginMetadataLifecycleCaches();
const { runGatewayClosePrelude } = await loadGatewayCloseModule();
await runGatewayClosePrelude({
@ -1466,6 +1519,8 @@ export async function startGatewayServer(
nodeUnsubscribeAll,
hasConnectedTalkNode: hasTalkNodeConnected,
clients,
invalidateDeviceTransports: watchNodeHttpRuntime.invalidateSessionsForDevice,
disconnectDeviceTransports: watchNodeHttpRuntime.disconnectSessionsForDevice,
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => {
enforceSharedGatewaySessionGenerationForConfigWrite({
state: sharedGatewaySessionGenerationState,

View file

@ -27,14 +27,15 @@ function makeConnectParams(clientId: string) {
}
describe("connect params client id validation", () => {
test.each([GATEWAY_CLIENT_IDS.IOS_APP, GATEWAY_CLIENT_IDS.ANDROID_APP])(
"accepts %s as a valid gateway client id",
(clientId) => {
const ok = validateConnectParams(makeConnectParams(clientId));
expect(ok).toBe(true);
expect(validateConnectParams.errors ?? []).toHaveLength(0);
},
);
test.each([
GATEWAY_CLIENT_IDS.IOS_APP,
GATEWAY_CLIENT_IDS.WATCHOS_APP,
GATEWAY_CLIENT_IDS.ANDROID_APP,
])("accepts %s as a valid gateway client id", (clientId) => {
const ok = validateConnectParams(makeConnectParams(clientId));
expect(ok).toBe(true);
expect(validateConnectParams.errors ?? []).toHaveLength(0);
});
test("rejects unknown client ids", () => {
const ok = validateConnectParams(makeConnectParams("openclaw-mobile"));

View file

@ -0,0 +1,579 @@
import { createServer, type Server } from "node:http";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { PROTOCOL_VERSION, type ConnectParams } from "../../packages/gateway-protocol/src/index.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { revokeDeviceToken } from "../infra/device-pairing.js";
import { listNodePairing } from "../infra/node-pairing.js";
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
import { createWatchNodeHttpRuntime, testing } from "./watch-node-http.js";
const tempDirs = createTrackedTempDirs();
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
await tempDirs.cleanup();
});
function makeConnectParams(params: {
identity: ReturnType<typeof loadOrCreateDeviceIdentity>;
nonce: string;
bootstrapToken?: string;
deviceToken?: string;
}): ConnectParams {
const publicKey = publicKeyRawBase64UrlFromPem(params.identity.publicKeyPem);
const auth = params.deviceToken
? { deviceToken: params.deviceToken }
: { bootstrapToken: params.bootstrapToken };
const signedAt = Date.now();
const client = {
id: GATEWAY_CLIENT_IDS.WATCHOS_APP,
displayName: "Test Watch",
version: "1.0.0",
platform: "watchOS 11.5.0",
deviceFamily: "Apple Watch",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: "watch-test",
} as const;
const scopes: string[] = [];
const signaturePayload = buildDeviceAuthPayloadV3({
deviceId: params.identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role: "node",
scopes,
signedAtMs: signedAt,
token: params.deviceToken ?? params.bootstrapToken ?? null,
nonce: params.nonce,
platform: client.platform,
deviceFamily: client.deviceFamily,
});
return {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client,
caps: [],
commands: ["device.info", "device.status", "system.notify"],
permissions: { notifications: true },
role: "node",
scopes,
auth,
device: {
id: params.identity.deviceId,
publicKey,
signature: signDevicePayload(params.identity.privateKeyPem, signaturePayload),
signedAt,
nonce: params.nonce,
},
} as ConnectParams;
}
async function startRuntime(
baseDir: string,
options?: { rateLimiter?: AuthRateLimiter; abortConnectResponse?: boolean },
) {
const nodeRegistry = new NodeRegistry();
const broadcasts: Array<{ event: string; payload: unknown }> = [];
const connectedNodes: string[] = [];
const disconnectedNodes: Array<{ nodeId: string; reason: string }> = [];
const runtime = createWatchNodeHttpRuntime({
nodeRegistry,
getConfig: () => ({}),
pairingBaseDir: baseDir,
broadcast: (event, payload) => broadcasts.push({ event, payload }),
onNodeConnected: (session) => connectedNodes.push(session.nodeId),
onNodeDisconnected: (nodeId, reason) => disconnectedNodes.push({ nodeId, reason }),
...(options?.rateLimiter ? { rateLimiter: options.rateLimiter } : {}),
});
let resolveConnectHandled: () => void = () => undefined;
const connectHandled = new Promise<void>((resolve) => {
resolveConnectHandled = resolve;
});
const server = createServer((req, res) => {
const isConnect = req.url === "/api/nodes/watch/connect";
if (isConnect && options?.abortConnectResponse) {
res.end = (() => {
res.destroy();
return res;
}) as typeof res.end;
}
void runtime
.handleRequest(req, res)
.then((handled) => {
if (!handled && !res.writableEnded) {
res.statusCode = 404;
res.end();
}
})
.finally(() => {
if (isConnect) {
resolveConnectHandled();
}
});
});
servers.push(server);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected TCP server address");
}
return {
nodeRegistry,
broadcasts,
connectedNodes,
disconnectedNodes,
runtime,
connectHandled,
baseUrl: `http://127.0.0.1:${address.port}/api/nodes/watch`,
};
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Promise<void> {
await vi.waitFor(async () => {
const paired = (await listNodePairing(baseDir)).paired.find((entry) => entry.nodeId === nodeId);
expect(paired?.lastConnectedAtMs).toEqual(expect.any(Number));
});
}
describe("watch node HTTP transport", () => {
it("accepts only the canonical bounded watch surface", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-surface-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const bounded = makeConnectParams({ identity, nonce: "nonce", bootstrapToken: "token" });
expect(testing.isCanonicalWatchNode(bounded)).toBe(true);
expect(
testing.isCanonicalWatchNode({
...bounded,
permissions: { notifications: false },
}),
).toBe(true);
expect(
testing.isCanonicalWatchNode({
...bounded,
commands: [...(bounded.commands ?? []), "system.run"],
}),
).toBe(false);
expect(testing.isCanonicalWatchNode({ ...bounded, caps: ["canvas"] })).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
commands: [...(bounded.commands ?? []), "canvas.present"],
}),
).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
permissions: { ...bounded.permissions, canvas: true },
}),
).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
client: { ...bounded.client, deviceFamily: "iPhone" },
}),
).toBe(false);
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 3, maxProtocol: 3 })).toBe(
false,
);
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 4, maxProtocol: 3 })).toBe(
false,
);
const challenges = testing.createChallengeStore();
const legitimate = challenges.issue("legitimate-client", 1_000);
for (let index = 0; index < 32; index += 1) {
challenges.issue("attacker", 1_000 + index);
}
expect(challenges.consume(legitimate.nonce, "legitimate-client", 2_000)).toBe(true);
});
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
const sessionToken = String(connected.sessionToken);
expect(connectedNodes).toEqual([identity.deviceId]);
const unauthenticated = await fetch(`${baseUrl}/disconnect`, { method: "POST" });
expect(unauthenticated.status).toBe(401);
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
expect(disconnectedNodes).toEqual([]);
const wrongMethod = await fetch(`${baseUrl}/disconnect`, {
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(wrongMethod.status).toBe(405);
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
const disconnectResponse = await fetch(`${baseUrl}/disconnect`, {
method: "POST",
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(disconnectResponse.status).toBe(200);
await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toEqual([
{ nodeId: identity.deviceId, reason: "watch disconnected" },
]);
const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, {
method: "POST",
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(repeatedDisconnect.status).toBe(401);
runtime.close();
expect(disconnectedNodes).toHaveLength(1);
});
it("rejects empty shadow credentials without consuming the challenge", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connect = makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
});
const shadowedResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...connect,
auth: { ...connect.auth, token: "" },
}),
});
expect(shadowedResponse.status).toBe(401);
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(connect),
});
expect(connectResponse.status).toBe(200);
await readJson(connectResponse);
await waitForLastConnectedMetadata(baseDir, identity.deviceId);
runtime.close();
});
it("keeps challenge throttling after abort and resets it after completion", async () => {
const limiterConfig = {
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
pruneIntervalMs: 0,
};
const abortedBaseDir = await tempDirs.make("openclaw-watch-node-aborted-connect-");
const abortedIdentity = loadOrCreateDeviceIdentity(
path.join(abortedBaseDir, "watch-identity.json"),
);
const abortedBootstrap = await issueDeviceBootstrapToken({
baseDir: abortedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const abortedLimiter = createAuthRateLimiter(limiterConfig);
try {
const abortedRuntime = await startRuntime(abortedBaseDir, {
rateLimiter: abortedLimiter,
abortConnectResponse: true,
});
const challenge = await readJson(await fetch(`${abortedRuntime.baseUrl}/challenge`));
await expect(
fetch(`${abortedRuntime.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: abortedIdentity,
nonce: String(challenge.nonce),
bootstrapToken: abortedBootstrap.token,
}),
),
}),
).rejects.toThrow();
await abortedRuntime.connectHandled;
const stillLimited = await fetch(`${abortedRuntime.baseUrl}/challenge`);
expect(stillLimited.status).toBe(429);
abortedRuntime.runtime.close();
} finally {
abortedLimiter.dispose();
}
const completedBaseDir = await tempDirs.make("openclaw-watch-node-completed-connect-");
const completedIdentity = loadOrCreateDeviceIdentity(
path.join(completedBaseDir, "watch-identity.json"),
);
const completedBootstrap = await issueDeviceBootstrapToken({
baseDir: completedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const completedLimiter = createAuthRateLimiter(limiterConfig);
try {
const completedRuntime = await startRuntime(completedBaseDir, {
rateLimiter: completedLimiter,
});
const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`));
const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: completedIdentity,
nonce: String(challenge.nonce),
bootstrapToken: completedBootstrap.token,
}),
),
});
expect(connectResponse.status).toBe(200);
await readJson(connectResponse);
await completedRuntime.connectHandled;
await waitForLastConnectedMetadata(completedBaseDir, completedIdentity.deviceId);
const resetAfterCompletion = await fetch(`${completedRuntime.baseUrl}/challenge`);
expect(resetAfterCompletion.status).toBe(200);
completedRuntime.runtime.close();
} finally {
completedLimiter.dispose();
}
});
it("bootstraps, registers, polls an invoke, and accepts its result", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-http-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
expect(connected.sessionToken).toEqual(expect.any(String));
expect(connected.deviceToken).toEqual(expect.any(String));
expect(nodeRegistry.get(identity.deviceId)?.commands).toEqual([
"device.info",
"device.status",
"system.notify",
]);
expect(broadcasts.map((entry) => entry.event)).toContain("device.pair.resolved");
expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved");
expect(connectedNodes).toEqual([identity.deviceId]);
const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const reconnectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(reconnectChallenge.nonce),
deviceToken: String(connected.deviceToken),
}),
),
});
expect(reconnectResponse.status).toBe(200);
const reconnected = await readJson(reconnectResponse);
expect(reconnected.deviceToken).toBe(connected.deviceToken);
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId]);
expect(disconnectedNodes).toEqual([]);
const stalePollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(connected.sessionToken)}` },
});
expect(stalePollResponse.status).toBe(401);
const invoke = nodeRegistry.invoke({
nodeId: identity.deviceId,
command: "device.info",
timeoutMs: 2_000,
});
const pollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
});
expect(pollResponse.status).toBe(200);
const polled = await readJson(pollResponse);
const event = polled.event as { event: string; payload: { id: string } };
expect(event.event).toBe("node.invoke.request");
const resultResponse = await fetch(`${baseUrl}/result`, {
method: "POST",
headers: {
authorization: `Bearer ${String(reconnected.sessionToken)}`,
"content-type": "application/json",
},
body: JSON.stringify({ id: event.payload.id, ok: true, payloadJSON: '{"model":"Watch"}' }),
});
expect(resultResponse.status).toBe(200);
await expect(invoke).resolves.toMatchObject({
ok: true,
payloadJSON: '{"model":"Watch"}',
});
const lateResultResponse = await fetch(`${baseUrl}/result`, {
method: "POST",
headers: {
authorization: `Bearer ${String(reconnected.sessionToken)}`,
"content-type": "application/json",
},
body: JSON.stringify({ id: event.payload.id, ok: true }),
});
expect(lateResultResponse.status).toBe(200);
await expect(readJson(lateResultResponse)).resolves.toEqual({ ok: true, ignored: true });
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({ ok: true });
runtime.invalidateSessionsForDevice(identity.deviceId, {
role: "node",
reason: "device-token-revoked",
});
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({
ok: false,
error: { code: "NOT_CONNECTED", message: "device-token-revoked" },
});
runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({
nodeId: identity.deviceId,
reason: "device-token-revoked",
});
const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
});
expect(invalidatedPollResponse.status).toBe(401);
const revoked = await revokeDeviceToken({
deviceId: identity.deviceId,
role: "node",
baseDir,
});
expect(revoked.ok).toBe(true);
const replacementBootstrap = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replacementResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replacementChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
});
expect(replacementResponse.status).toBe(200);
const replacement = await readJson(replacementResponse);
expect(replacement.deviceToken).toEqual(expect.any(String));
expect(replacement.deviceToken).not.toBe(connected.deviceToken);
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]);
const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replayResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replayChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
});
expect(replayResponse.status).toBe(401);
const rawPayload = serializeEventPayload({ sequence: 1 });
expect(rawPayload).not.toBeNull();
expect(nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", rawPayload)).toBe(
true,
);
const rawPollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(replacement.sessionToken)}` },
});
expect(rawPollResponse.status).toBe(200);
await expect(readJson(rawPollResponse)).resolves.toMatchObject({
ok: true,
event: { event: "node.invoke.request", payload: { sequence: 1 } },
});
const oversizedPayload = serializeEventPayload({ value: "x".repeat(70 * 1024) });
expect(oversizedPayload).not.toBeNull();
expect(
nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload),
).toBe(false);
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({
nodeId: identity.deviceId,
reason: "event payload too large",
});
runtime.close();
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
});
});

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,7 @@ describe("pairing setup code", () => {
url?: string;
urls?: string[];
urlSource?: string;
bootstrapProfile?: { roles: string[]; scopes: string[] };
},
) {
expect(resolved.ok).toBe(true);
@ -123,7 +124,7 @@ describe("pairing setup code", () => {
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
baseDir: undefined,
profile: {
profile: params.bootstrapProfile ?? {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
},
@ -155,6 +156,7 @@ describe("pairing setup code", () => {
url: string;
urls?: string[];
urlSource: string;
bootstrapProfile?: { roles: string[]; scopes: string[] };
};
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
expectedRunCommandCalls?: number;
@ -261,6 +263,23 @@ describe("pairing setup code", () => {
});
});
it("issues a node-only bootstrap profile for companion setup", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
forceSecure: true,
publicUrl: "gateway.example.test:18789/setup",
bootstrapProfile: { roles: ["node"], scopes: [] },
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789",
urlSource: "plugins.entries.device-pair.config.publicUrl",
bootstrapProfile: { roles: ["node"], scopes: [] },
},
});
});
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
await expectResolvedSetupFailureCase({
config: {

View file

@ -23,7 +23,10 @@ import {
pickMatchingExternalInterfaceAddress,
safeNetworkInterfaces,
} from "../infra/network-interfaces.js";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import {
PAIRING_SETUP_BOOTSTRAP_PROFILE,
type DeviceBootstrapProfileInput,
} from "../shared/device-bootstrap-profile.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import {
resolveTailnetHostWithRunner,
@ -55,6 +58,7 @@ export type ResolvePairingSetupOptions = {
publicUrl?: string;
preferRemoteUrl?: boolean;
forceSecure?: boolean;
bootstrapProfile?: DeviceBootstrapProfileInput;
pairingBaseDir?: string;
runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
@ -435,7 +439,7 @@ export async function resolvePairingSetupFromConfig(
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
profile: options.bootstrapProfile ?? PAIRING_SETUP_BOOTSTRAP_PROFILE,
})
).token,
},

View file

@ -2,7 +2,9 @@
import { describe, expect, test } from "vitest";
import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
isNodePairingSetupBootstrapProfile,
isPairingSetupBootstrapProfile,
normalizeDeviceBootstrapHandoffProfile,
resolveBootstrapProfileScopesForRole,
@ -76,6 +78,12 @@ describe("device bootstrap profile", () => {
});
});
test("node setup profile carries no operator access", () => {
expect(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node"], scopes: [] });
expect(isNodePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
expect(isPairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
});
test("recognizes only the current setup profile", () => {
expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
expect(

View file

@ -32,21 +32,37 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
};
/** Node-only setup profile for companions that never act as operators. */
export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node"],
scopes: [],
};
function matchesBootstrapProfile(
input: DeviceBootstrapProfileInput | undefined,
expected: DeviceBootstrapProfile,
): boolean {
const profile = normalizeDeviceBootstrapProfile(input);
return (
profile.roles.length === expected.roles.length &&
profile.scopes.length === expected.scopes.length &&
profile.roles.every((role, index) => role === expected.roles[index]) &&
profile.scopes.every((scope, index) => scope === expected.scopes[index])
);
}
/** Return whether an input exactly matches the current setup-code bootstrap profile. */
export function isPairingSetupBootstrapProfile(
input: DeviceBootstrapProfileInput | undefined,
): boolean {
const profile = normalizeDeviceBootstrapProfile(input);
if (profile.roles.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.roles.length) {
return false;
}
if (profile.scopes.length !== PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes.length) {
return false;
}
return (
profile.roles.every((role, index) => role === PAIRING_SETUP_BOOTSTRAP_PROFILE.roles[index]) &&
profile.scopes.every((scope, index) => scope === PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes[index])
);
return matchesBootstrapProfile(input, PAIRING_SETUP_BOOTSTRAP_PROFILE);
}
/** Return whether an input exactly matches the node-only companion setup profile. */
export function isNodePairingSetupBootstrapProfile(
input: DeviceBootstrapProfileInput | undefined,
): boolean {
return matchesBootstrapProfile(input, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE);
}
/** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */