diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index dc94f3d0797..4d44c82258b 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -174,7 +174,12 @@ final class GatewayConnectionController { let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) if resolvedUseTLS, stored == nil { guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } - guard let fp = await self.probeTLSFingerprint(url: url) else { return } + guard let fp = await self.probeTLSFingerprint(url: url) else { + self.appModel?.gatewayStatusText = + "TLS handshake failed for \(host):\(resolvedPort). " + + "Remote gateways must use HTTPS/WSS." + return + } self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true) self.pendingTrustPrompt = TrustPrompt( stableID: stableID, diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 516e7b373eb..ce5362264ca 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -607,7 +607,7 @@ struct OnboardingWizardView: View { private var authStep: some View { Group { Section("Authentication") { - TextField("Gateway Auth Token", text: self.$gatewayToken) + SecureField("Gateway Auth Token", text: self.$gatewayToken) .textInputAutocapitalization(.never) .autocorrectionDisabled() SecureField("Gateway Password", text: self.$gatewayPassword) @@ -724,6 +724,12 @@ struct OnboardingWizardView: View { TextField("Discovery Domain (optional)", text: self.$discoveryDomain) .textInputAutocapitalization(.never) .autocorrectionDisabled() + if self.selectedMode == .remoteDomain { + SecureField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Gateway Password", text: self.$gatewayPassword) + } self.manualConnectButton } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 92413aefe64..df987c3b910 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -289,6 +289,17 @@ public final class OpenClawChatViewModel { stopReason: message.stopReason) } + private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String { + message.content.map { item in + let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return [type, text, id, name, fileName].joined(separator: "\\u{001F}") + }.joined(separator: "\\u{001E}") + } + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !role.isEmpty else { return nil } @@ -298,15 +309,7 @@ public final class OpenClawChatViewModel { return String(format: "%.3f", value) }() - let contentFingerprint = message.content.map { item in - let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return [type, text, id, name, fileName].joined(separator: "\\u{001F}") - }.joined(separator: "\\u{001E}") - + let contentFingerprint = Self.messageContentFingerprint(for: message) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { @@ -315,6 +318,19 @@ public final class OpenClawChatViewModel { return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|") } + private static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard role == "user" else { return nil } + + let contentFingerprint = Self.messageContentFingerprint(for: message) + let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { + return nil + } + return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|") + } + private static func reconcileMessageIDs( previous: [OpenClawChatMessage], incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] @@ -353,6 +369,75 @@ public final class OpenClawChatViewModel { } } + private static func reconcileRunRefreshMessages( + previous: [OpenClawChatMessage], + incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] + { + guard !previous.isEmpty else { return incoming } + guard !incoming.isEmpty else { return previous } + + func countKeys(_ keys: [String]) -> [String: Int] { + keys.reduce(into: [:]) { counts, key in + counts[key, default: 0] += 1 + } + } + + var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming) + let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:))) + var remainingIncomingUserRefreshCounts = countKeys( + reconciled.compactMap(Self.userRefreshIdentityKey(for:))) + + var lastMatchedPreviousIndex: Int? + for (index, message) in previous.enumerated() { + if let key = Self.messageIdentityKey(for: message), + incomingIdentityKeys.contains(key) + { + lastMatchedPreviousIndex = index + continue + } + if let userKey = Self.userRefreshIdentityKey(for: message), + let remaining = remainingIncomingUserRefreshCounts[userKey], + remaining > 0 + { + remainingIncomingUserRefreshCounts[userKey] = remaining - 1 + lastMatchedPreviousIndex = index + } + } + + let trailingUserMessages = (lastMatchedPreviousIndex != nil + ? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!)) + : ArraySlice(previous)) + .filter { message in + guard message.role.lowercased() == "user" else { return false } + guard let key = Self.userRefreshIdentityKey(for: message) else { return false } + let remaining = remainingIncomingUserRefreshCounts[key] ?? 0 + if remaining > 0 { + remainingIncomingUserRefreshCounts[key] = remaining - 1 + return false + } + return true + } + + guard !trailingUserMessages.isEmpty else { + return reconciled + } + + for message in trailingUserMessages { + guard let messageTimestamp = message.timestamp else { + reconciled.append(message) + continue + } + + let insertIndex = reconciled.firstIndex { existing in + guard let existingTimestamp = existing.timestamp else { return false } + return existingTimestamp > messageTimestamp + } ?? reconciled.endIndex + reconciled.insert(message, at: insertIndex) + } + + return Self.dedupeMessages(reconciled) + } + private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { var result: [OpenClawChatMessage] = [] result.reserveCapacity(messages.count) @@ -919,7 +1004,7 @@ public final class OpenClawChatViewModel { private func refreshHistoryAfterRun() async { do { let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.reconcileMessageIDs( + self.messages = Self.reconcileRunRefreshMessages( previous: self.messages, incoming: Self.decodeMessages(payload.messages ?? [])) self.sessionId = payload.sessionId diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 2c3da84af68..ba7bee46c6d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -513,8 +513,11 @@ public actor GatewayChannelActor { storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() let authToken = explicitToken ?? - (includeDeviceIdentity && explicitPassword == nil && - (explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil) + // A freshly scanned setup code should force the bootstrap pairing path instead of + // silently reusing an older stored device token. + (includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil + ? storedToken + : nil) let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authSource: GatewayAuthSource diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 6d1fa88e569..d918c90155d 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -126,6 +126,28 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a } } +@discardableResult +private func sendMessageAndEmitFinal( + transport: TestChatTransport, + vm: OpenClawChatViewModel, + text: String, + sessionKey: String = "main") async throws -> String +{ + await sendUserMessage(vm, text: text) + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: sessionKey, + state: "final", + message: nil, + errorMessage: nil))) + return runId +} + private func emitAssistantText( transport: TestChatTransport, runId: String, @@ -439,6 +461,141 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } + @Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(sessionId: sessionId) + let history2 = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "assistant", + text: "final answer", + timestamp: now + 1), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") + + try await waitUntil("assistant history refreshes without dropping user message") { + await MainActor.run { + let texts = vm.messages.map { message in + (message.role, message.content.compactMap(\.text).joined(separator: "\n")) + } + return texts.contains(where: { $0.0 == "assistant" && $0.1 == "final answer" }) && + texts.contains(where: { $0.0 == "user" && $0.1 == "hello from mac webchat" }) + } + } + } + + @Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws { + let sessionId = "sess-main" + let history1 = historyPayload(sessionId: sessionId) + let history2 = historyPayload(sessionId: sessionId, messages: []) + + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") + + try await waitUntil("empty refresh does not clear optimistic user message") { + await MainActor.run { + vm.messages.contains { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat" + } + } + } + } + + @Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(sessionId: sessionId) + let history2 = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "hello from mac webchat", + timestamp: now + 5_000), + chatTextMessage( + role: "assistant", + text: "final answer", + timestamp: now + 6_000), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") + + try await waitUntil("canonical refresh keeps one user message") { + await MainActor.run { + let userMessages = vm.messages.filter { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "hello from mac webchat" + } + let hasAssistant = vm.messages.contains { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "final answer" + } + return hasAssistant && userMessages.count == 1 + } + } + } + + @Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(sessionId: sessionId) + let history2 = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "retry", + timestamp: now + 5_000), + chatTextMessage( + role: "assistant", + text: "first answer", + timestamp: now + 6_000), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "retry") + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "retry") + + try await waitUntil("repeated optimistic user message is preserved") { + await MainActor.run { + let retryMessages = vm.messages.filter { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "retry" + } + let hasAssistant = vm.messages.contains { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "first answer" + } + return hasAssistant && retryMessages.count == 2 + } + } + } + @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { let history1 = historyPayload() let history2 = historyPayload( diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 183fc385d8c..b8c57ba6a2b 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -15,6 +15,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda private let lock = NSLock() private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? + private var connectAuth: [String: Any]? private var receivePhase = 0 private var pendingReceiveHandler: (@Sendable (Result) -> Void)? @@ -50,10 +51,18 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda obj["method"] as? String == "connect", let id = obj["id"] as? String { - self.lock.withLock { self.connectRequestId = id } + let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:] + self.lock.withLock { + self.connectRequestId = id + self.connectAuth = auth + } } } + func latestConnectAuth() -> [String: Any]? { + self.lock.withLock { self.connectAuth } + } + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { pongReceiveHandler(nil) } @@ -169,6 +178,62 @@ private actor SeqGapProbe { } struct GatewayNodeSessionTests { + @Test + func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "stored-device-token") + + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "operator", + scopes: ["operator.read"], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "ui", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true) + + try await gateway.connect( + url: URL(string: "ws://example.invalid")!, + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let auth = try #require(session.latestTask()?.latestConnectAuth()) + #expect(auth["bootstrapToken"] as? String == "fresh-bootstrap-token") + #expect(auth["token"] == nil) + #expect(auth["deviceToken"] == nil) + + await gateway.disconnect() + } + @Test func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { let normalized = canonicalizeCanvasHostUrl( diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index eb4001b8a91..e528b6a3a42 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -1,8 +1,14 @@ export { approveDevicePairing, + clearDeviceBootstrapTokens, issueDeviceBootstrapToken, listDevicePairing, + revokeDeviceBootstrapToken, } from "openclaw/plugin-sdk/device-bootstrap"; export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core"; -export { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/sandbox"; +export { + resolvePreferredOpenClawTmpDir, + runPluginCommandWithTimeout, +} from "openclaw/plugin-sdk/sandbox"; +export { renderQrPngBase64 } from "./qr-image.js"; diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts new file mode 100644 index 00000000000..bf9f47217f3 --- /dev/null +++ b/extensions/device-pair/index.test.ts @@ -0,0 +1,359 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + OpenClawPluginCommandDefinition, + PluginCommandContext, +} from "openclaw/plugin-sdk/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; +import type { OpenClawPluginApi } from "./api.js"; + +const pluginApiMocks = vi.hoisted(() => ({ + clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })), + issueDeviceBootstrapToken: vi.fn(async () => ({ + token: "boot-token", + expiresAtMs: Date.now() + 10 * 60_000, + })), + revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })), + renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="), + resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")), +})); + +vi.mock("./api.js", () => { + return { + approveDevicePairing: vi.fn(), + clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens, + definePluginEntry: vi.fn((entry) => entry), + issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken, + listDevicePairing: vi.fn(async () => ({ pending: [] })), + renderQrPngBase64: pluginApiMocks.renderQrPngBase64, + revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken, + resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir, + resolveGatewayBindUrl: vi.fn(), + resolveTailnetHostWithRunner: vi.fn(), + runPluginCommandWithTimeout: vi.fn(), + }; +}); + +vi.mock("./notify.js", () => ({ + armPairNotifyOnce: vi.fn(async () => false), + formatPendingRequests: vi.fn(() => "No pending device pairing requests."), + handleNotifyCommand: vi.fn(async () => ({ text: "notify" })), + registerPairingNotifierService: vi.fn(), +})); + +import registerDevicePair from "./index.js"; + +function createApi(params?: { + runtime?: OpenClawPluginApi["runtime"]; + pluginConfig?: Record; + registerCommand?: (command: OpenClawPluginCommandDefinition) => void; +}): OpenClawPluginApi { + return createTestPluginApi({ + id: "device-pair", + name: "device-pair", + source: "test", + config: { + gateway: { + auth: { + mode: "token", + token: "gateway-token", + }, + }, + }, + pluginConfig: { + publicUrl: "ws://51.79.175.165:18789", + ...(params?.pluginConfig ?? {}), + }, + runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"], + registerCommand: params?.registerCommand, + }) as OpenClawPluginApi; +} + +function registerPairCommand(params?: { + runtime?: OpenClawPluginApi["runtime"]; + pluginConfig?: Record; +}): OpenClawPluginCommandDefinition { + let command: OpenClawPluginCommandDefinition | undefined; + registerDevicePair.register( + createApi({ + ...params, + registerCommand: (nextCommand) => { + command = nextCommand; + }, + }), + ); + expect(command).toBeTruthy(); + return command!; +} + +function createChannelRuntime( + runtimeKey: string, + sendKey: string, + sendMessage: (...args: unknown[]) => Promise, +): OpenClawPluginApi["runtime"] { + return { + channel: { + [runtimeKey]: { + [sendKey]: sendMessage, + }, + }, + } as unknown as OpenClawPluginApi["runtime"]; +} + +function createCommandContext(params?: Partial): PluginCommandContext { + return { + channel: "webchat", + isAuthorizedSender: true, + commandBody: "/pair qr", + args: "qr", + config: {}, + requestConversationBinding: async () => ({ + status: "error", + message: "unsupported", + }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + ...params, + }; +} + +describe("device-pair /pair qr", () => { + beforeEach(async () => { + vi.clearAllMocks(); + pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({ + token: "boot-token", + expiresAtMs: Date.now() + 10 * 60_000, + }); + await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true, force: true }); + }); + + it("returns an inline QR image for webchat surfaces", async () => { + const command = registerPairCommand(); + const result = await command?.handler(createCommandContext({ channel: "webchat" })); + + expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1); + expect(result?.text).toContain("Scan this QR code with the OpenClaw iOS app:"); + expect(result?.text).toContain("![OpenClaw pairing QR](data:image/png;base64,ZmFrZXBuZw==)"); + expect(result?.text).toContain("- Security: single-use bootstrap token"); + expect(result?.text).toContain("**Important:** Run `/pair cleanup` after pairing finishes."); + expect(result?.text).toContain("If this QR code leaks, run `/pair cleanup` immediately."); + expect(result?.text).not.toContain("```"); + }); + + it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => { + pluginApiMocks.issueDeviceBootstrapToken + .mockResolvedValueOnce({ + token: "first-token", + expiresAtMs: Date.now() + 10 * 60_000, + }) + .mockResolvedValueOnce({ + token: "second-token", + expiresAtMs: Date.now() + 10 * 60_000, + }); + pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed")); + + const command = registerPairCommand(); + const result = await command?.handler(createCommandContext({ channel: "webchat" })); + + expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ + token: "first-token", + }); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2); + expect(result?.text).toContain( + "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.", + ); + expect(result?.text).toContain("Pairing setup code generated."); + }); + + it.each([ + { + label: "Telegram", + runtimeKey: "telegram", + sendKey: "sendMessageTelegram", + ctx: { + channel: "telegram", + senderId: "123", + accountId: "default", + messageThreadId: 271, + }, + expectedTarget: "123", + expectedOpts: { + accountId: "default", + messageThreadId: 271, + }, + }, + { + label: "Discord", + runtimeKey: "discord", + sendKey: "sendMessageDiscord", + ctx: { + channel: "discord", + senderId: "123", + accountId: "default", + }, + expectedTarget: "user:123", + expectedOpts: { + accountId: "default", + }, + }, + { + label: "Slack", + runtimeKey: "slack", + sendKey: "sendMessageSlack", + ctx: { + channel: "slack", + senderId: "user:U123", + accountId: "default", + messageThreadId: "1234567890.000001", + }, + expectedTarget: "user:U123", + expectedOpts: { + accountId: "default", + threadTs: "1234567890.000001", + }, + }, + { + label: "Signal", + runtimeKey: "signal", + sendKey: "sendMessageSignal", + ctx: { + channel: "signal", + senderId: "signal:+15551234567", + accountId: "default", + }, + expectedTarget: "signal:+15551234567", + expectedOpts: { + accountId: "default", + }, + }, + { + label: "iMessage", + runtimeKey: "imessage", + sendKey: "sendMessageIMessage", + ctx: { + channel: "imessage", + senderId: "+15551234567", + accountId: "default", + }, + expectedTarget: "+15551234567", + expectedOpts: { + accountId: "default", + }, + }, + { + label: "WhatsApp", + runtimeKey: "whatsapp", + sendKey: "sendMessageWhatsApp", + ctx: { + channel: "whatsapp", + senderId: "+15551234567", + accountId: "default", + }, + expectedTarget: "+15551234567", + expectedOpts: { + accountId: "default", + verbose: false, + }, + }, + ])("sends $label a real QR image attachment", async (testCase) => { + let sentPng = ""; + const sendMessage = vi.fn().mockImplementation(async (_target, _caption, opts) => { + if (opts?.mediaUrl) { + sentPng = await fs.readFile(opts.mediaUrl, "utf8"); + } + return { messageId: "1" }; + }); + const command = registerPairCommand({ + runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage), + }); + + const result = await command?.handler(createCommandContext(testCase.ctx)); + + expect(sendMessage).toHaveBeenCalledTimes(1); + const [target, caption, opts] = sendMessage.mock.calls[0] as [ + string, + string, + { + mediaUrl?: string; + mediaLocalRoots?: string[]; + accountId?: string; + } & Record, + ]; + expect(target).toBe(testCase.expectedTarget); + expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:"); + expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); + expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately."); + expect(opts.mediaUrl).toMatch(/pair-qr\.png$/); + expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]); + expect(opts).toMatchObject(testCase.expectedOpts); + expect(sentPng).toBe("fakepng"); + await expect(fs.access(opts.mediaUrl!)).rejects.toBeTruthy(); + expect(result?.text).toContain("QR code sent above."); + expect(result?.text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes."); + }); + + it("reissues the bootstrap token after QR delivery failure before falling back", async () => { + pluginApiMocks.issueDeviceBootstrapToken + .mockResolvedValueOnce({ + token: "first-token", + expiresAtMs: Date.now() + 10 * 60_000, + }) + .mockResolvedValueOnce({ + token: "second-token", + expiresAtMs: Date.now() + 10 * 60_000, + }); + + const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed")); + const command = registerPairCommand({ + runtime: createChannelRuntime("discord", "sendMessageDiscord", sendMessage), + }); + + const result = await command?.handler( + createCommandContext({ + channel: "discord", + senderId: "123", + }), + ); + + expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ + token: "first-token", + }); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2); + expect(result?.text).toContain("Pairing setup code generated."); + expect(result?.text).toContain("If this code leaks or you are done, run /pair cleanup"); + }); + + it("falls back to the setup code instead of ASCII when the channel cannot send media", async () => { + const command = registerPairCommand(); + const result = await command?.handler( + createCommandContext({ + channel: "msteams", + senderId: "8:orgid:123", + }), + ); + + expect(result?.text).toContain("QR image delivery is not available on this channel"); + expect(result?.text).toContain("Setup code:"); + expect(result?.text).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); + expect(result?.text).not.toContain("```"); + }); + + it("supports invalidating unused setup codes", async () => { + const command = registerPairCommand(); + const result = await command?.handler( + createCommandContext({ + args: "cleanup", + commandBody: "/pair cleanup", + }), + ); + + expect(pluginApiMocks.clearDeviceBootstrapTokens).toHaveBeenCalledTimes(1); + expect(result).toEqual({ text: "Invalidated 2 unused setup codes." }); + }); +}); diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index defd3b5c4c6..7a416b2b56e 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,13 +1,18 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; -import qrcode from "qrcode-terminal"; +import path from "node:path"; import { approveDevicePairing, + clearDeviceBootstrapTokens, definePluginEntry, issueDeviceBootstrapToken, listDevicePairing, + renderQrPngBase64, + revokeDeviceBootstrapToken, resolveGatewayBindUrl, - runPluginCommandWithTimeout, + resolvePreferredOpenClawTmpDir, resolveTailnetHostWithRunner, + runPluginCommandWithTimeout, type OpenClawPluginApi, } from "./api.js"; import { @@ -17,12 +22,24 @@ import { registerPairingNotifierService, } from "./notify.js"; -function renderQrAscii(data: string): Promise { - return new Promise((resolve) => { - qrcode.generate(data, { small: true }, (output: string) => { - resolve(output); - }); - }); +async function renderQrDataUrl(data: string): Promise { + const pngBase64 = await renderQrPngBase64(data); + return `data:image/png;base64,${pngBase64}`; +} + +async function writeQrPngTempFile(data: string): Promise { + const pngBase64 = await renderQrPngBase64(data); + const tmpRoot = resolvePreferredOpenClawTmpDir(); + const qrDir = await mkdtemp(path.join(tmpRoot, "device-pair-qr-")); + const filePath = path.join(qrDir, "pair-qr.png"); + await writeFile(filePath, Buffer.from(pngBase64, "base64")); + return filePath; +} + +function formatDurationMinutes(expiresAtMs: number): string { + const msRemaining = Math.max(0, expiresAtMs - Date.now()); + const minutes = Math.max(1, Math.ceil(msRemaining / 60_000)); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; } const DEFAULT_GATEWAY_PORT = 18789; @@ -34,6 +51,7 @@ type DevicePairPluginConfig = { type SetupPayload = { url: string; bootstrapToken: string; + expiresAtMs: number; }; type ResolveUrlResult = { @@ -47,6 +65,85 @@ type ResolveAuthLabelResult = { error?: string; }; +type QrCommandContext = { + channel: string; + senderId?: string; + from?: string; + to?: string; + accountId?: string; + messageThreadId?: string | number; +}; + +type QrChannelSender = { + resolveSend: (api: OpenClawPluginApi) => QrSendFn | undefined; + createOpts: (params: { + ctx: QrCommandContext; + qrFilePath: string; + mediaLocalRoots: string[]; + accountId?: string; + }) => Record; +}; + +type QrSendFn = (to: string, text: string, opts: Record) => Promise; + +function coerceQrSend(send: unknown): QrSendFn | undefined { + return typeof send === "function" ? (send as QrSendFn) : undefined; +} + +const QR_CHANNEL_SENDERS: Record = { + telegram: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.telegram?.sendMessageTelegram), + createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(typeof ctx.messageThreadId === "number" ? { messageThreadId: ctx.messageThreadId } : {}), + ...(accountId ? { accountId } : {}), + }), + }, + discord: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.discord?.sendMessageDiscord), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + slack: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.slack?.sendMessageSlack), + createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(ctx.messageThreadId != null ? { threadTs: String(ctx.messageThreadId) } : {}), + ...(accountId ? { accountId } : {}), + }), + }, + signal: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.signal?.sendMessageSignal), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + imessage: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.imessage?.sendMessageIMessage), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + whatsapp: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.whatsapp?.sendMessageWhatsApp), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + verbose: false, + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, +}; + function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -299,33 +396,172 @@ function encodeSetupCode(payload: SetupPayload): string { return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } +function buildPairingFlowLines(stepTwo: string): string[] { + return [ + "1) Open the iOS app → Settings → Gateway", + `2) ${stepTwo}`, + "3) Back here, run /pair approve", + "4) If this code leaks or you are done, run /pair cleanup", + ]; +} + +function buildSecurityNoticeLines(params: { + kind: "setup code" | "QR code"; + expiresAtMs: number; + markdown?: boolean; +}): string[] { + const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup"; + const securityPrefix = params.markdown ? "- " : ""; + const importantLine = params.markdown + ? `**Important:** Run ${cleanupCommand} after pairing finishes.` + : `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`; + return [ + `${securityPrefix}Security: single-use bootstrap token`, + `${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`, + "", + importantLine, + `If this ${params.kind} leaks, run ${cleanupCommand} immediately.`, + ]; +} + +function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] { + return autoNotifyArmed + ? [ + "After scanning, wait here for the pairing request ping.", + "I’ll auto-ping here when the pairing request arrives, then auto-disable.", + "If the ping does not arrive, run `/pair approve latest` manually.", + ] + : ["After scanning, run `/pair approve` to complete pairing."]; +} + function formatSetupReply(payload: SetupPayload, authLabel: string): string { const setupCode = encodeSetupCode(payload); return [ "Pairing setup code generated.", "", - "1) Open the iOS app → Settings → Gateway", - "2) Paste the setup code below and tap Connect", - "3) Back here, run /pair approve", + ...buildPairingFlowLines("Paste the setup code below and tap Connect"), "", "Setup code:", setupCode, "", `Gateway: ${payload.url}`, `Auth: ${authLabel}`, + ...buildSecurityNoticeLines({ + kind: "setup code", + expiresAtMs: payload.expiresAtMs, + }), ].join("\n"); } -function formatSetupInstructions(): string { +function formatSetupInstructions(expiresAtMs: number): string { return [ "Pairing setup code generated.", "", - "1) Open the iOS app → Settings → Gateway", - "2) Paste the setup code from my next message and tap Connect", - "3) Back here, run /pair approve", + ...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"), + "", + ...buildSecurityNoticeLines({ + kind: "setup code", + expiresAtMs, + }), ].join("\n"); } +function buildQrInfoLines(params: { + payload: SetupPayload; + authLabel: string; + autoNotifyArmed: boolean; + expiresAtMs: number; +}): string[] { + return [ + `Gateway: ${params.payload.url}`, + `Auth: ${params.authLabel}`, + ...buildSecurityNoticeLines({ + kind: "QR code", + expiresAtMs: params.expiresAtMs, + }), + "", + ...buildQrFollowUpLines(params.autoNotifyArmed), + "", + "If your camera still won’t lock on, run `/pair` for a pasteable setup code.", + ]; +} + +function formatQrInfoMarkdown(params: { + payload: SetupPayload; + authLabel: string; + autoNotifyArmed: boolean; + expiresAtMs: number; +}): string { + return [ + `- Gateway: ${params.payload.url}`, + `- Auth: ${params.authLabel}`, + ...buildSecurityNoticeLines({ + kind: "QR code", + expiresAtMs: params.expiresAtMs, + markdown: true, + }), + "", + ...buildQrFollowUpLines(params.autoNotifyArmed), + "", + "If your camera still won’t lock on, run `/pair` for a pasteable setup code.", + ].join("\n"); +} + +function canSendQrPngToChannel(channel: string): boolean { + return channel in QR_CHANNEL_SENDERS; +} + +function resolveQrReplyTarget(ctx: QrCommandContext): string { + if (ctx.channel === "discord") { + const senderId = ctx.senderId?.trim() ?? ""; + if (senderId) { + return senderId.startsWith("user:") || senderId.startsWith("channel:") + ? senderId + : `user:${senderId}`; + } + } + return ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; +} + +async function issueSetupPayload(url: string): Promise { + const issuedBootstrap = await issueDeviceBootstrapToken(); + return { + url, + bootstrapToken: issuedBootstrap.token, + expiresAtMs: issuedBootstrap.expiresAtMs, + }; +} + +async function sendQrPngToSupportedChannel(params: { + api: OpenClawPluginApi; + ctx: QrCommandContext; + target: string; + caption: string; + qrFilePath: string; +}): Promise { + const mediaLocalRoots = [path.dirname(params.qrFilePath)]; + const accountId = params.ctx.accountId?.trim() || undefined; + const sender = QR_CHANNEL_SENDERS[params.ctx.channel]; + if (!sender) { + return false; + } + const send = sender.resolveSend(params.api); + if (!send) { + return false; + } + await send( + params.target, + params.caption, + sender.createOpts({ + ctx: params.ctx, + qrFilePath: params.qrFilePath, + mediaLocalRoots, + accountId, + }), + ); + return true; +} + export default definePluginEntry({ id: "device-pair", name: "Device Pair", @@ -400,6 +636,16 @@ export default definePluginEntry({ return { text: `✅ Paired ${label}${platformLabel}.` }; } + if (action === "cleanup" || action === "clear" || action === "revoke") { + const cleared = await clearDeviceBootstrapTokens(); + return { + text: + cleared.removed > 0 + ? `Invalidated ${cleared.removed} unused setup code${cleared.removed === 1 ? "" : "s"}.` + : "No unused setup codes were active.", + }; + } + const authLabelResult = resolveAuthLabel(api.config); if (authLabelResult.error) { return { text: `Error: ${authLabelResult.error}` }; @@ -409,19 +655,11 @@ export default definePluginEntry({ if (!urlResult.url) { return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; } - - const payload: SetupPayload = { - url: urlResult.url, - bootstrapToken: (await issueDeviceBootstrapToken()).token, - }; + const authLabel = authLabelResult.label ?? "auth"; if (action === "qr") { - const setupCode = encodeSetupCode(payload); - const qrAscii = await renderQrAscii(setupCode); - const authLabel = authLabelResult.label ?? "auth"; - const channel = ctx.channel; - const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; + const target = resolveQrReplyTarget(ctx); let autoNotifyArmed = false; if (channel === "telegram" && target) { @@ -436,82 +674,99 @@ export default definePluginEntry({ } } - if (channel === "telegram" && target) { + let payload = await issueSetupPayload(urlResult.url); + let setupCode = encodeSetupCode(payload); + + const infoLines = buildQrInfoLines({ + payload, + authLabel, + autoNotifyArmed, + expiresAtMs: payload.expiresAtMs, + }); + + if (target && canSendQrPngToChannel(channel)) { + let qrFilePath: string | undefined; try { - const send = api.runtime?.channel?.telegram?.sendMessageTelegram; - if (send) { - await send( - target, - ["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join( - "\n", - ), - { - ...(ctx.messageThreadId != null - ? { messageThreadId: ctx.messageThreadId } - : {}), - ...(ctx.accountId ? { accountId: ctx.accountId } : {}), - }, - ); + qrFilePath = await writeQrPngTempFile(setupCode); + const sent = await sendQrPngToSupportedChannel({ + api, + ctx, + target, + caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join( + "\n", + ), + qrFilePath, + }); + if (sent) { return { - text: [ - `Gateway: ${payload.url}`, - `Auth: ${authLabel}`, - "", - autoNotifyArmed - ? "After scanning, wait here for the pairing request ping." - : "After scanning, come back here and run `/pair approve` to complete pairing.", - ...(autoNotifyArmed - ? [ - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : []), - ].join("\n"), + text: + `QR code sent above.\n` + + `Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` + + "IMPORTANT: Run /pair cleanup after pairing finishes.", }; } } catch (err) { api.logger.warn?.( - `device-pair: telegram QR send failed, falling back (${String( + `device-pair: QR image send failed channel=${channel}, falling back (${String( (err as Error)?.message ?? err, )})`, ); + await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {}); + payload = await issueSetupPayload(urlResult.url); + setupCode = encodeSetupCode(payload); + } finally { + if (qrFilePath) { + await rm(path.dirname(qrFilePath), { recursive: true, force: true }).catch( + () => {}, + ); + } } } - // Render based on channel capability api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`); - const infoLines = [ - `Gateway: ${payload.url}`, - `Auth: ${authLabel}`, - "", - autoNotifyArmed - ? "After scanning, wait here for the pairing request ping." - : "After scanning, run `/pair approve` to complete pairing.", - ...(autoNotifyArmed - ? [ - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : []), - ]; + if (channel === "webchat") { + let qrDataUrl: string; + try { + qrDataUrl = await renderQrDataUrl(setupCode); + } catch (err) { + api.logger.warn?.( + `device-pair: webchat QR render failed, falling back (${String( + (err as Error)?.message ?? err, + )})`, + ); + await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {}); + payload = await issueSetupPayload(urlResult.url); + return { + text: + "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" + + formatSetupReply(payload, authLabel), + }; + } + return { + text: [ + "Scan this QR code with the OpenClaw iOS app:", + "", + formatQrInfoMarkdown({ + payload, + authLabel, + autoNotifyArmed, + expiresAtMs: payload.expiresAtMs, + }), + "", + `![OpenClaw pairing QR](${qrDataUrl})`, + ].join("\n"), + }; + } - // WebUI + CLI/TUI: ASCII QR return { - text: [ - "Scan this QR code with the OpenClaw iOS app:", - "", - "```", - qrAscii, - "```", - "", - ...infoLines, - ].join("\n"), + text: + "QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" + + formatSetupReply(payload, authLabel), }; } - const channel = ctx.channel; const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; - const authLabel = authLabelResult.label ?? "auth"; + const payload = await issueSetupPayload(urlResult.url); if (channel === "telegram" && target) { try { @@ -530,8 +785,10 @@ export default definePluginEntry({ )})`, ); } - await send(target, formatSetupInstructions(), { - ...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), + await send(target, formatSetupInstructions(payload.expiresAtMs), { + ...(typeof ctx.messageThreadId === "number" + ? { messageThreadId: ctx.messageThreadId } + : {}), ...(ctx.accountId ? { accountId: ctx.accountId } : {}), }); api.logger.info?.( @@ -548,7 +805,6 @@ export default definePluginEntry({ ); } } - return { text: formatSetupReply(payload, authLabel), }; diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index 90e0e1890ab..e5d07174c2a 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -10,7 +10,7 @@ const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000; type NotifySubscription = { to: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; mode: "persistent" | "once"; addedAtMs: number; }; @@ -101,9 +101,11 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile { ? record.accountId.trim() : undefined; const messageThreadId = - typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId) - ? Math.trunc(record.messageThreadId) - : undefined; + typeof record.messageThreadId === "string" + ? record.messageThreadId.trim() || undefined + : typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId) + ? Math.trunc(record.messageThreadId) + : undefined; const mode = record.mode === "once" ? "once" : "persistent"; const addedAtMs = typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs) @@ -150,7 +152,7 @@ async function writeNotifyState(filePath: string, state: NotifyStateFile): Promi function notifySubscriberKey(subscriber: { to: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }): string { return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|"); } @@ -158,7 +160,7 @@ function notifySubscriberKey(subscriber: { type NotifyTarget = { to: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }; function resolveNotifyTarget(ctx: { @@ -166,7 +168,7 @@ function resolveNotifyTarget(ctx: { from?: string; to?: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }): NotifyTarget | null { const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; if (!to) { @@ -261,7 +263,7 @@ async function notifySubscriber(params: { try { await send(params.subscriber.to, params.text, { ...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}), - ...(params.subscriber.messageThreadId != null + ...(typeof params.subscriber.messageThreadId === "number" ? { messageThreadId: params.subscriber.messageThreadId } : {}), }); @@ -347,7 +349,7 @@ export async function armPairNotifyOnce(params: { from?: string; to?: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }; }): Promise { if (params.ctx.channel !== "telegram") { @@ -381,7 +383,7 @@ export async function handleNotifyCommand(params: { from?: string; to?: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }; action: string; }): Promise<{ text: string }> { diff --git a/extensions/device-pair/qr-image.ts b/extensions/device-pair/qr-image.ts new file mode 100644 index 00000000000..be6b10f5b0e --- /dev/null +++ b/extensions/device-pair/qr-image.ts @@ -0,0 +1,54 @@ +import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; +import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; +import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; + +type QRCodeConstructor = new ( + typeNumber: number, + errorCorrectLevel: unknown, +) => { + addData: (data: string) => void; + make: () => void; + getModuleCount: () => number; + isDark: (row: number, col: number) => boolean; +}; + +const QRCode = QRCodeModule as QRCodeConstructor; +const QRErrorCorrectLevel = QRErrorCorrectLevelModule; + +function createQrMatrix(input: string) { + const qr = new QRCode(-1, QRErrorCorrectLevel.L); + qr.addData(input); + qr.make(); + return qr; +} + +export async function renderQrPngBase64( + input: string, + opts: { scale?: number; marginModules?: number } = {}, +): Promise { + const { scale = 6, marginModules = 4 } = opts; + const qr = createQrMatrix(input); + const modules = qr.getModuleCount(); + const size = (modules + marginModules * 2) * scale; + + const buf = Buffer.alloc(size * size * 4, 255); + for (let row = 0; row < modules; row += 1) { + for (let col = 0; col < modules; col += 1) { + if (!qr.isDark(row, col)) { + continue; + } + const startX = (col + marginModules) * scale; + const startY = (row + marginModules) * scale; + for (let y = 0; y < scale; y += 1) { + const pixelY = startY + y; + for (let x = 0; x < scale; x += 1) { + const pixelX = startX + x; + fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); + } + } + } + } + + const png = encodePngRgba(buf, size, size); + return png.toString("base64"); +} diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index e76f0f25e73..d40bd87e315 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -43,7 +43,10 @@ export const handlePluginCommand: CommandHandler = async ( to: command.to, accountId: params.ctx.AccountId ?? undefined, messageThreadId: - typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined, + typeof params.ctx.MessageThreadId === "string" || + typeof params.ctx.MessageThreadId === "number" + ? params.ctx.MessageThreadId + : undefined, }); return { diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 45fef0a8d84..6136074d4b2 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -3,8 +3,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { + clearDeviceBootstrapTokens, DEVICE_BOOTSTRAP_TOKEN_TTL_MS, issueDeviceBootstrapToken, + revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "./device-bootstrap.js"; @@ -15,6 +17,22 @@ function resolveBootstrapPath(baseDir: string): string { return path.join(baseDir, "devices", "bootstrap.json"); } +async function verifyBootstrapToken( + baseDir: string, + token: string, + overrides: Partial[0]> = {}, +) { + return await verifyDeviceBootstrapToken({ + token, + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + ...overrides, + }); +} + afterEach(async () => { vi.useRealTimers(); await tempDirs.cleanup(); @@ -47,43 +65,85 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); }); + it("clears outstanding bootstrap tokens on demand", async () => { + const baseDir = await createTempDir(); + const first = await issueDeviceBootstrapToken({ baseDir }); + const second = await issueDeviceBootstrapToken({ baseDir }); + + await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 }); + await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); + + await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); + + await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); + }); + + it("revokes a specific bootstrap token", async () => { + const baseDir = await createTempDir(); + const first = await issueDeviceBootstrapToken({ baseDir }); + const second = await issueDeviceBootstrapToken({ baseDir }); + + await expect(revokeDeviceBootstrapToken({ baseDir, token: first.token })).resolves.toEqual({ + removed: true, + }); + + await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); + + await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true }); + }); + + it("consumes bootstrap tokens by the persisted map key", async () => { + const baseDir = await createTempDir(); + const issued = await issueDeviceBootstrapToken({ baseDir }); + const issuedAtMs = Date.now(); + const bootstrapPath = path.join(baseDir, "devices", "bootstrap.json"); + await fs.writeFile( + bootstrapPath, + JSON.stringify( + { + "legacy-key": { + token: issued.token, + ts: issuedAtMs, + issuedAtMs, + }, + }, + null, + 2, + ), + "utf8", + ); + + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); + + await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}"); + }); + it("keeps the token when required verification fields are blank", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", + verifyBootstrapToken(baseDir, issued.token, { role: " ", - scopes: ["operator.admin"], - baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); @@ -95,16 +155,9 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: ` ${issued.token} `, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({ + ok: true, + }); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); }); @@ -113,16 +166,10 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: " ", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect( verifyDeviceBootstrapToken({ @@ -179,26 +226,11 @@ describe("device bootstrap tokens", () => { "utf8", ); - await expect( - verifyDeviceBootstrapToken({ - token: "legacyToken", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceBootstrapToken({ - token: "expiredToken", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); }); }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index d4d2d6ed526..6a38c16d1ea 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -79,6 +79,41 @@ export async function issueDeviceBootstrapToken( }); } +export async function clearDeviceBootstrapTokens( + params: { + baseDir?: string; + } = {}, +): Promise<{ removed: number }> { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const removed = Object.keys(state).length; + await persistState({}, params.baseDir); + return { removed }; + }); +} + +export async function revokeDeviceBootstrapToken(params: { + token: string; + baseDir?: string; +}): Promise<{ removed: boolean }> { + return await withLock(async () => { + const providedToken = params.token.trim(); + if (!providedToken) { + return { removed: false }; + } + const state = await loadState(params.baseDir); + const found = Object.entries(state).find(([, candidate]) => + verifyPairingToken(providedToken, candidate.token), + ); + if (!found) { + return { removed: false }; + } + delete state[found[0]]; + await persistState(state, params.baseDir); + return { removed: true }; + }); +} + export async function verifyDeviceBootstrapToken(params: { token: string; deviceId: string; @@ -93,12 +128,13 @@ export async function verifyDeviceBootstrapToken(params: { if (!providedToken) { return { ok: false, reason: "bootstrap_token_invalid" }; } - const entry = Object.values(state).find((candidate) => + const found = Object.entries(state).find(([, candidate]) => verifyPairingToken(providedToken, candidate.token), ); - if (!entry) { + if (!found) { return { ok: false, reason: "bootstrap_token_invalid" }; } + const [tokenKey] = found; const deviceId = params.deviceId.trim(); const publicKey = params.publicKey.trim(); @@ -109,7 +145,7 @@ export async function verifyDeviceBootstrapToken(params: { // Bootstrap setup codes are single-use. Consume the record before returning // success so the same token cannot be replayed to mutate a pending request. - delete state[entry.token]; + delete state[tokenKey]; await persistState(state, params.baseDir); return { ok: true }; }); diff --git a/src/plugin-sdk/device-bootstrap.ts b/src/plugin-sdk/device-bootstrap.ts index c3ecf15ab51..6b2c933fc27 100644 --- a/src/plugin-sdk/device-bootstrap.ts +++ b/src/plugin-sdk/device-bootstrap.ts @@ -1,4 +1,8 @@ // Shared bootstrap/pairing helpers for plugins that provision remote devices. export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; -export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; +export { + clearDeviceBootstrapTokens, + issueDeviceBootstrapToken, + revokeDeviceBootstrapToken, +} from "../infra/device-bootstrap.js"; diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 8137ebbed1b..85d73d7cabc 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -322,7 +322,7 @@ function resolveBindingConversationFromCommand(params: { from?: string; to?: string; accountId?: string; - messageThreadId?: number; + messageThreadId?: string | number; }): { channel: string; accountId: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 343a338c4f8..db27be43007 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -963,7 +963,7 @@ export type PluginCommandContext = { /** Account id for multi-account channels */ accountId?: string; /** Thread/topic id if available */ - messageThreadId?: number; + messageThreadId?: string | number; requestConversationBinding: ( params?: PluginConversationBindingRequestParams, ) => Promise;