Compare commits

...

5 Commits

Author SHA1 Message Date
Val Alexander
2fd372836e
iOS: improve QR pairing flow (#51359)
- improve QR pairing UX and bootstrap token handling
- preserve repeated optimistic user messages during refresh
- add regression coverage for refresh reconciliation

Thanks @ImLukeF
2026-03-21 01:10:29 -05:00
Ayaan Zaidi
ce6a48195a
test: fix whatsapp config-runtime mock store path 2026-03-21 11:39:21 +05:30
Ayaan Zaidi
8a05c05596
fix: defer plugin runtime globals until use 2026-03-21 11:14:48 +05:30
scoootscooob
43513cd1df
test: refresh plugin import boundary baseline (#51434) 2026-03-20 22:36:11 -07:00
Ted Li
5bb5d7dab4
CLI: respect full timeout for loopback gateway probes (#47533)
* CLI: respect loopback gateway probe timeout

* CLI: name gateway probe budgets

* CLI: keep inactive loopback probes fast

* CLI: inline simple gateway probe caps

* Update helpers.ts

* Gateway: clamp probe timeout to timer-safe max

* fix: note loopback gateway probe timeout fix (#47533) (thanks @MonkeyLeeT)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-21 10:57:50 +05:30
32 changed files with 1510 additions and 305 deletions

View File

@ -194,6 +194,7 @@ Docs: https://docs.openclaw.ai
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan. - Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob. - Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob. - Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
### Breaking ### Breaking

View File

@ -174,7 +174,12 @@ final class GatewayConnectionController {
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil { if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } 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.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
self.pendingTrustPrompt = TrustPrompt( self.pendingTrustPrompt = TrustPrompt(
stableID: stableID, stableID: stableID,

View File

@ -607,7 +607,7 @@ struct OnboardingWizardView: View {
private var authStep: some View { private var authStep: some View {
Group { Group {
Section("Authentication") { Section("Authentication") {
TextField("Gateway Auth Token", text: self.$gatewayToken) SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword) SecureField("Gateway Password", text: self.$gatewayPassword)
@ -724,6 +724,12 @@ struct OnboardingWizardView: View {
TextField("Discovery Domain (optional)", text: self.$discoveryDomain) TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
if self.selectedMode == .remoteDomain {
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
}
self.manualConnectButton self.manualConnectButton
} }
} }

View File

@ -289,6 +289,17 @@ public final class OpenClawChatViewModel {
stopReason: message.stopReason) 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? { private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil } guard !role.isEmpty else { return nil }
@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
return String(format: "%.3f", value) return String(format: "%.3f", value)
}() }()
let contentFingerprint = message.content.map { item in let contentFingerprint = Self.messageContentFingerprint(for: message)
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 toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { 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: "|") 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( private static func reconcileMessageIDs(
previous: [OpenClawChatMessage], previous: [OpenClawChatMessage],
incoming: [OpenClawChatMessage]) -> [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] { private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
var result: [OpenClawChatMessage] = [] var result: [OpenClawChatMessage] = []
result.reserveCapacity(messages.count) result.reserveCapacity(messages.count)
@ -919,7 +1004,7 @@ public final class OpenClawChatViewModel {
private func refreshHistoryAfterRun() async { private func refreshHistoryAfterRun() async {
do { do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.reconcileMessageIDs( self.messages = Self.reconcileRunRefreshMessages(
previous: self.messages, previous: self.messages,
incoming: Self.decodeMessages(payload.messages ?? [])) incoming: Self.decodeMessages(payload.messages ?? []))
self.sessionId = payload.sessionId self.sessionId = payload.sessionId

View File

@ -513,8 +513,11 @@ public actor GatewayChannelActor {
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
let authToken = let authToken =
explicitToken ?? explicitToken ??
(includeDeviceIdentity && explicitPassword == nil && // A freshly scanned setup code should force the bootstrap pairing path instead of
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil) // silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource let authSource: GatewayAuthSource

View File

@ -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( private func emitAssistantText(
transport: TestChatTransport, transport: TestChatTransport,
runId: String, runId: String,
@ -439,6 +461,141 @@ extension TestChatTransportState {
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) #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 { @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
let history1 = historyPayload() let history1 = historyPayload()
let history2 = historyPayload( let history2 = historyPayload(

View File

@ -15,6 +15,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private let lock = NSLock() private let lock = NSLock()
private var _state: URLSessionTask.State = .suspended private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String? private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var receivePhase = 0 private var receivePhase = 0
private var pendingReceiveHandler: private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@ -50,10 +51,18 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect", obj["method"] as? String == "connect",
let id = obj["id"] as? String 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) { func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil) pongReceiveHandler(nil)
} }
@ -169,6 +178,62 @@ private actor SeqGapProbe {
} }
struct GatewayNodeSessionTests { 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 @Test
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl( let normalized = canonicalizeCanvasHostUrl(

View File

@ -1,8 +1,14 @@
export { export {
approveDevicePairing, approveDevicePairing,
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken, issueDeviceBootstrapToken,
listDevicePairing, listDevicePairing,
revokeDeviceBootstrapToken,
} from "openclaw/plugin-sdk/device-bootstrap"; } from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core"; 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";

View File

@ -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<string, unknown>;
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<string, unknown>;
}): 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<unknown>,
): OpenClawPluginApi["runtime"] {
return {
channel: {
[runtimeKey]: {
[sendKey]: sendMessage,
},
},
} as unknown as OpenClawPluginApi["runtime"];
}
function createCommandContext(params?: Partial<PluginCommandContext>): 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<string, unknown>,
];
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." });
});
});

View File

@ -1,13 +1,18 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import qrcode from "qrcode-terminal"; import path from "node:path";
import { import {
approveDevicePairing, approveDevicePairing,
clearDeviceBootstrapTokens,
definePluginEntry, definePluginEntry,
issueDeviceBootstrapToken, issueDeviceBootstrapToken,
listDevicePairing, listDevicePairing,
renderQrPngBase64,
revokeDeviceBootstrapToken,
resolveGatewayBindUrl, resolveGatewayBindUrl,
runPluginCommandWithTimeout, resolvePreferredOpenClawTmpDir,
resolveTailnetHostWithRunner, resolveTailnetHostWithRunner,
runPluginCommandWithTimeout,
type OpenClawPluginApi, type OpenClawPluginApi,
} from "./api.js"; } from "./api.js";
import { import {
@ -17,12 +22,24 @@ import {
registerPairingNotifierService, registerPairingNotifierService,
} from "./notify.js"; } from "./notify.js";
function renderQrAscii(data: string): Promise<string> { async function renderQrDataUrl(data: string): Promise<string> {
return new Promise((resolve) => { const pngBase64 = await renderQrPngBase64(data);
qrcode.generate(data, { small: true }, (output: string) => { return `data:image/png;base64,${pngBase64}`;
resolve(output); }
});
}); async function writeQrPngTempFile(data: string): Promise<string> {
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; const DEFAULT_GATEWAY_PORT = 18789;
@ -34,6 +51,7 @@ type DevicePairPluginConfig = {
type SetupPayload = { type SetupPayload = {
url: string; url: string;
bootstrapToken: string; bootstrapToken: string;
expiresAtMs: number;
}; };
type ResolveUrlResult = { type ResolveUrlResult = {
@ -47,6 +65,85 @@ type ResolveAuthLabelResult = {
error?: string; 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<string, unknown>;
};
type QrSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
function coerceQrSend(send: unknown): QrSendFn | undefined {
return typeof send === "function" ? (send as QrSendFn) : undefined;
}
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
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 { function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = raw.trim(); const candidate = raw.trim();
if (!candidate) { if (!candidate) {
@ -299,33 +396,172 @@ function encodeSetupCode(payload: SetupPayload): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 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.",
"Ill 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 { function formatSetupReply(payload: SetupPayload, authLabel: string): string {
const setupCode = encodeSetupCode(payload); const setupCode = encodeSetupCode(payload);
return [ return [
"Pairing setup code generated.", "Pairing setup code generated.",
"", "",
"1) Open the iOS app → Settings → Gateway", ...buildPairingFlowLines("Paste the setup code below and tap Connect"),
"2) Paste the setup code below and tap Connect",
"3) Back here, run /pair approve",
"", "",
"Setup code:", "Setup code:",
setupCode, setupCode,
"", "",
`Gateway: ${payload.url}`, `Gateway: ${payload.url}`,
`Auth: ${authLabel}`, `Auth: ${authLabel}`,
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs: payload.expiresAtMs,
}),
].join("\n"); ].join("\n");
} }
function formatSetupInstructions(): string { function formatSetupInstructions(expiresAtMs: number): string {
return [ return [
"Pairing setup code generated.", "Pairing setup code generated.",
"", "",
"1) Open the iOS app → Settings → Gateway", ...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
"2) Paste the setup code from my next message and tap Connect", "",
"3) Back here, run /pair approve", ...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs,
}),
].join("\n"); ].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 wont 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 wont 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<SetupPayload> {
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<boolean> {
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({ export default definePluginEntry({
id: "device-pair", id: "device-pair",
name: "Device Pair", name: "Device Pair",
@ -400,6 +636,16 @@ export default definePluginEntry({
return { text: `✅ Paired ${label}${platformLabel}.` }; 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); const authLabelResult = resolveAuthLabel(api.config);
if (authLabelResult.error) { if (authLabelResult.error) {
return { text: `Error: ${authLabelResult.error}` }; return { text: `Error: ${authLabelResult.error}` };
@ -409,19 +655,11 @@ export default definePluginEntry({
if (!urlResult.url) { if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
} }
const authLabel = authLabelResult.label ?? "auth";
const payload: SetupPayload = {
url: urlResult.url,
bootstrapToken: (await issueDeviceBootstrapToken()).token,
};
if (action === "qr") { if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
const authLabel = authLabelResult.label ?? "auth";
const channel = ctx.channel; const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; const target = resolveQrReplyTarget(ctx);
let autoNotifyArmed = false; let autoNotifyArmed = false;
if (channel === "telegram" && target) { 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 { try {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram; qrFilePath = await writeQrPngTempFile(setupCode);
if (send) { const sent = await sendQrPngToSupportedChannel({
await send( api,
target, ctx,
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join( target,
"\n", caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
), "\n",
{ ),
...(ctx.messageThreadId != null qrFilePath,
? { messageThreadId: ctx.messageThreadId } });
: {}), if (sent) {
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
},
);
return { return {
text: [ text:
`Gateway: ${payload.url}`, `QR code sent above.\n` +
`Auth: ${authLabel}`, `Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
"", "IMPORTANT: Run /pair cleanup after pairing finishes.",
autoNotifyArmed
? "After scanning, wait here for the pairing request ping."
: "After scanning, come back here and run `/pair approve` to complete pairing.",
...(autoNotifyArmed
? [
"Ill auto-ping here when the pairing request arrives, then auto-disable.",
"If the ping does not arrive, run `/pair approve latest` manually.",
]
: []),
].join("\n"),
}; };
} }
} catch (err) { } catch (err) {
api.logger.warn?.( 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, (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}`); api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
const infoLines = [ if (channel === "webchat") {
`Gateway: ${payload.url}`, let qrDataUrl: string;
`Auth: ${authLabel}`, try {
"", qrDataUrl = await renderQrDataUrl(setupCode);
autoNotifyArmed } catch (err) {
? "After scanning, wait here for the pairing request ping." api.logger.warn?.(
: "After scanning, run `/pair approve` to complete pairing.", `device-pair: webchat QR render failed, falling back (${String(
...(autoNotifyArmed (err as Error)?.message ?? err,
? [ )})`,
"Ill auto-ping here when the pairing request arrives, then auto-disable.", );
"If the ping does not arrive, run `/pair approve latest` manually.", 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 { return {
text: [ text:
"Scan this QR code with the OpenClaw iOS app:", "QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
"", formatSetupReply(payload, authLabel),
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
}; };
} }
const channel = ctx.channel; const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; 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) { if (channel === "telegram" && target) {
try { try {
@ -530,8 +785,10 @@ export default definePluginEntry({
)})`, )})`,
); );
} }
await send(target, formatSetupInstructions(), { await send(target, formatSetupInstructions(payload.expiresAtMs), {
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}), ...(typeof ctx.messageThreadId === "number"
? { messageThreadId: ctx.messageThreadId }
: {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}), ...(ctx.accountId ? { accountId: ctx.accountId } : {}),
}); });
api.logger.info?.( api.logger.info?.(
@ -548,7 +805,6 @@ export default definePluginEntry({
); );
} }
} }
return { return {
text: formatSetupReply(payload, authLabel), text: formatSetupReply(payload, authLabel),
}; };

View File

@ -10,7 +10,7 @@ const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
type NotifySubscription = { type NotifySubscription = {
to: string; to: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
mode: "persistent" | "once"; mode: "persistent" | "once";
addedAtMs: number; addedAtMs: number;
}; };
@ -101,9 +101,11 @@ function normalizeNotifyState(raw: unknown): NotifyStateFile {
? record.accountId.trim() ? record.accountId.trim()
: undefined; : undefined;
const messageThreadId = const messageThreadId =
typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId) typeof record.messageThreadId === "string"
? Math.trunc(record.messageThreadId) ? record.messageThreadId.trim() || undefined
: undefined; : typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
const mode = record.mode === "once" ? "once" : "persistent"; const mode = record.mode === "once" ? "once" : "persistent";
const addedAtMs = const addedAtMs =
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs) typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
@ -150,7 +152,7 @@ async function writeNotifyState(filePath: string, state: NotifyStateFile): Promi
function notifySubscriberKey(subscriber: { function notifySubscriberKey(subscriber: {
to: string; to: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}): string { }): string {
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|"); return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
} }
@ -158,7 +160,7 @@ function notifySubscriberKey(subscriber: {
type NotifyTarget = { type NotifyTarget = {
to: string; to: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}; };
function resolveNotifyTarget(ctx: { function resolveNotifyTarget(ctx: {
@ -166,7 +168,7 @@ function resolveNotifyTarget(ctx: {
from?: string; from?: string;
to?: string; to?: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}): NotifyTarget | null { }): NotifyTarget | null {
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (!to) { if (!to) {
@ -261,7 +263,7 @@ async function notifySubscriber(params: {
try { try {
await send(params.subscriber.to, params.text, { await send(params.subscriber.to, params.text, {
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}), ...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
...(params.subscriber.messageThreadId != null ...(typeof params.subscriber.messageThreadId === "number"
? { messageThreadId: params.subscriber.messageThreadId } ? { messageThreadId: params.subscriber.messageThreadId }
: {}), : {}),
}); });
@ -347,7 +349,7 @@ export async function armPairNotifyOnce(params: {
from?: string; from?: string;
to?: string; to?: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}; };
}): Promise<boolean> { }): Promise<boolean> {
if (params.ctx.channel !== "telegram") { if (params.ctx.channel !== "telegram") {
@ -381,7 +383,7 @@ export async function handleNotifyCommand(params: {
from?: string; from?: string;
to?: string; to?: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}; };
action: string; action: string;
}): Promise<{ text: string }> { }): Promise<{ text: string }> {

View File

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

View File

@ -51,16 +51,18 @@ type FeishuThreadBindingsState = {
}; };
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState"); const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
const state = resolveGlobalSingleton<FeishuThreadBindingsState>( let state: FeishuThreadBindingsState | undefined;
FEISHU_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
}),
);
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId; function getState(): FeishuThreadBindingsState {
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation; state ??= resolveGlobalSingleton<FeishuThreadBindingsState>(
FEISHU_THREAD_BINDINGS_STATE_KEY,
() => ({
managersByAccountId: new Map(),
bindingsByAccountConversation: new Map(),
}),
);
return state;
}
function resolveBindingKey(params: { accountId: string; conversationId: string }): string { function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
return `${params.accountId}:${params.conversationId}`; return `${params.accountId}:${params.conversationId}`;
@ -119,7 +121,7 @@ export function createFeishuThreadBindingManager(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
}): FeishuThreadBindingManager { }): FeishuThreadBindingManager {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); const existing = getState().managersByAccountId.get(accountId);
if (existing) { if (existing) {
return existing; return existing;
} }
@ -138,9 +140,11 @@ export function createFeishuThreadBindingManager(params: {
const manager: FeishuThreadBindingManager = { const manager: FeishuThreadBindingManager = {
accountId, accountId,
getByConversationId: (conversationId) => getByConversationId: (conversationId) =>
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })), getState().bindingsByAccountConversation.get(
resolveBindingKey({ accountId, conversationId }),
),
listBySessionKey: (targetSessionKey) => listBySessionKey: (targetSessionKey) =>
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( [...getState().bindingsByAccountConversation.values()].filter(
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey, (record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
), ),
bindConversation: ({ bindConversation: ({
@ -184,7 +188,7 @@ export function createFeishuThreadBindingManager(params: {
boundAt: now, boundAt: now,
lastActivityAt: now, lastActivityAt: now,
}; };
BINDINGS_BY_ACCOUNT_CONVERSATION.set( getState().bindingsByAccountConversation.set(
resolveBindingKey({ accountId, conversationId: normalizedConversationId }), resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
record, record,
); );
@ -192,30 +196,30 @@ export function createFeishuThreadBindingManager(params: {
}, },
touchConversation: (conversationId, at = Date.now()) => { touchConversation: (conversationId, at = Date.now()) => {
const key = resolveBindingKey({ accountId, conversationId }); const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); const existingRecord = getState().bindingsByAccountConversation.get(key);
if (!existingRecord) { if (!existingRecord) {
return null; return null;
} }
const updated = { ...existingRecord, lastActivityAt: at }; const updated = { ...existingRecord, lastActivityAt: at };
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated); getState().bindingsByAccountConversation.set(key, updated);
return updated; return updated;
}, },
unbindConversation: (conversationId) => { unbindConversation: (conversationId) => {
const key = resolveBindingKey({ accountId, conversationId }); const key = resolveBindingKey({ accountId, conversationId });
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); const existingRecord = getState().bindingsByAccountConversation.get(key);
if (!existingRecord) { if (!existingRecord) {
return null; return null;
} }
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); getState().bindingsByAccountConversation.delete(key);
return existingRecord; return existingRecord;
}, },
unbindBySessionKey: (targetSessionKey) => { unbindBySessionKey: (targetSessionKey) => {
const removed: FeishuThreadBindingRecord[] = []; const removed: FeishuThreadBindingRecord[] = [];
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) { for (const record of [...getState().bindingsByAccountConversation.values()]) {
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) { if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
continue; continue;
} }
BINDINGS_BY_ACCOUNT_CONVERSATION.delete( getState().bindingsByAccountConversation.delete(
resolveBindingKey({ accountId, conversationId: record.conversationId }), resolveBindingKey({ accountId, conversationId: record.conversationId }),
); );
removed.push(record); removed.push(record);
@ -223,12 +227,12 @@ export function createFeishuThreadBindingManager(params: {
return removed; return removed;
}, },
stop: () => { stop: () => {
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) { for (const key of [...getState().bindingsByAccountConversation.keys()]) {
if (key.startsWith(`${accountId}:`)) { if (key.startsWith(`${accountId}:`)) {
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); getState().bindingsByAccountConversation.delete(key);
} }
} }
MANAGERS_BY_ACCOUNT_ID.delete(accountId); getState().managersByAccountId.delete(accountId);
unregisterSessionBindingAdapter({ channel: "feishu", accountId }); unregisterSessionBindingAdapter({ channel: "feishu", accountId });
}, },
}; };
@ -290,22 +294,22 @@ export function createFeishuThreadBindingManager(params: {
}, },
}); });
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); getState().managersByAccountId.set(accountId, manager);
return manager; return manager;
} }
export function getFeishuThreadBindingManager( export function getFeishuThreadBindingManager(
accountId?: string, accountId?: string,
): FeishuThreadBindingManager | null { ): FeishuThreadBindingManager | null {
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
} }
export const __testing = { export const __testing = {
resetFeishuThreadBindingsForTests() { resetFeishuThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { for (const manager of getState().managersByAccountId.values()) {
manager.stop(); manager.stop();
} }
MANAGERS_BY_ACCOUNT_ID.clear(); getState().managersByAccountId.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); getState().bindingsByAccountConversation.clear();
}, },
}; };

View File

@ -15,7 +15,12 @@ const MAX_ENTRIES = 5000;
*/ */
const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation"); const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation");
const threadParticipation = resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY); let threadParticipation: Map<string, number> | undefined;
function getThreadParticipation(): Map<string, number> {
threadParticipation ??= resolveGlobalMap<string, number>(SLACK_THREAD_PARTICIPATION_KEY);
return threadParticipation;
}
function makeKey(accountId: string, channelId: string, threadTs: string): string { function makeKey(accountId: string, channelId: string, threadTs: string): string {
return `${accountId}:${channelId}:${threadTs}`; return `${accountId}:${channelId}:${threadTs}`;
@ -23,17 +28,17 @@ function makeKey(accountId: string, channelId: string, threadTs: string): string
function evictExpired(): void { function evictExpired(): void {
const now = Date.now(); const now = Date.now();
for (const [key, timestamp] of threadParticipation) { for (const [key, timestamp] of getThreadParticipation()) {
if (now - timestamp > TTL_MS) { if (now - timestamp > TTL_MS) {
threadParticipation.delete(key); getThreadParticipation().delete(key);
} }
} }
} }
function evictOldest(): void { function evictOldest(): void {
const oldest = threadParticipation.keys().next().value; const oldest = getThreadParticipation().keys().next().value;
if (oldest) { if (oldest) {
threadParticipation.delete(oldest); getThreadParticipation().delete(oldest);
} }
} }
@ -45,6 +50,7 @@ export function recordSlackThreadParticipation(
if (!accountId || !channelId || !threadTs) { if (!accountId || !channelId || !threadTs) {
return; return;
} }
const threadParticipation = getThreadParticipation();
if (threadParticipation.size >= MAX_ENTRIES) { if (threadParticipation.size >= MAX_ENTRIES) {
evictExpired(); evictExpired();
} }
@ -63,6 +69,7 @@ export function hasSlackThreadParticipation(
return false; return false;
} }
const key = makeKey(accountId, channelId, threadTs); const key = makeKey(accountId, channelId, threadTs);
const threadParticipation = getThreadParticipation();
const timestamp = threadParticipation.get(key); const timestamp = threadParticipation.get(key);
if (timestamp == null) { if (timestamp == null) {
return false; return false;
@ -75,5 +82,5 @@ export function hasSlackThreadParticipation(
} }
export function clearSlackThreadParticipationCache(): void { export function clearSlackThreadParticipationCache(): void {
threadParticipation.clear(); getThreadParticipation().clear();
} }

View File

@ -28,11 +28,17 @@ type TelegramSendMessageDraft = (
*/ */
const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState"); const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState");
const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ let draftStreamState: { nextDraftId: number } | undefined;
nextDraftId: 0,
})); function getDraftStreamState(): { nextDraftId: number } {
draftStreamState ??= resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({
nextDraftId: 0,
}));
return draftStreamState;
}
function allocateTelegramDraftId(): number { function allocateTelegramDraftId(): number {
const draftStreamState = getDraftStreamState();
draftStreamState.nextDraftId = draftStreamState.nextDraftId =
draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1;
return draftStreamState.nextDraftId; return draftStreamState.nextDraftId;
@ -454,6 +460,6 @@ export function createTelegramDraftStream(params: {
export const __testing = { export const __testing = {
resetTelegramDraftStreamForTests() { resetTelegramDraftStreamForTests() {
draftStreamState.nextDraftId = 0; getDraftStreamState().nextDraftId = 0;
}, },
}; };

View File

@ -103,17 +103,34 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi; const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
const FILE_REFERENCE_PATTERN = new RegExp(
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
"gi",
);
const ORPHANED_TLD_PATTERN = new RegExp(
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
"g",
);
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi; const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
let fileReferencePattern: RegExp | undefined;
let orphanedTldPattern: RegExp | undefined;
function getFileReferencePattern(): RegExp {
if (fileReferencePattern) {
return fileReferencePattern;
}
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
fileReferencePattern = new RegExp(
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
"gi",
);
return fileReferencePattern;
}
function getOrphanedTldPattern(): RegExp {
if (orphanedTldPattern) {
return orphanedTldPattern;
}
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
orphanedTldPattern = new RegExp(
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`,
"g",
);
return orphanedTldPattern;
}
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string { function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
if (filename.startsWith("//")) { if (filename.startsWith("//")) {
@ -134,8 +151,8 @@ function wrapSegmentFileRefs(
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) { if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
return text; return text;
} }
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef); const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef);
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) => return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) =>
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`, prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
); );
} }

View File

@ -17,7 +17,12 @@ type CacheEntry = {
*/ */
const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages"); const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages");
const sentMessages = resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY); let sentMessages: Map<string, CacheEntry> | undefined;
function getSentMessages(): Map<string, CacheEntry> {
sentMessages ??= resolveGlobalMap<string, CacheEntry>(TELEGRAM_SENT_MESSAGES_KEY);
return sentMessages;
}
function getChatKey(chatId: number | string): string { function getChatKey(chatId: number | string): string {
return String(chatId); return String(chatId);
@ -37,6 +42,7 @@ function cleanupExpired(entry: CacheEntry): void {
*/ */
export function recordSentMessage(chatId: number | string, messageId: number): void { export function recordSentMessage(chatId: number | string, messageId: number): void {
const key = getChatKey(chatId); const key = getChatKey(chatId);
const sentMessages = getSentMessages();
let entry = sentMessages.get(key); let entry = sentMessages.get(key);
if (!entry) { if (!entry) {
entry = { timestamps: new Map() }; entry = { timestamps: new Map() };
@ -54,7 +60,7 @@ export function recordSentMessage(chatId: number | string, messageId: number): v
*/ */
export function wasSentByBot(chatId: number | string, messageId: number): boolean { export function wasSentByBot(chatId: number | string, messageId: number): boolean {
const key = getChatKey(chatId); const key = getChatKey(chatId);
const entry = sentMessages.get(key); const entry = getSentMessages().get(key);
if (!entry) { if (!entry) {
return false; return false;
} }
@ -67,5 +73,5 @@ export function wasSentByBot(chatId: number | string, messageId: number): boolea
* Clear all cached entries (for testing). * Clear all cached entries (for testing).
*/ */
export function clearSentMessageCache(): void { export function clearSentMessageCache(): void {
sentMessages.clear(); getSentMessages().clear();
} }

View File

@ -77,17 +77,19 @@ type TelegramThreadBindingsState = {
*/ */
const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState"); const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState");
const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>( let threadBindingsState: TelegramThreadBindingsState | undefined;
TELEGRAM_THREAD_BINDINGS_STATE_KEY,
() => ({ function getThreadBindingsState(): TelegramThreadBindingsState {
managersByAccountId: new Map<string, TelegramThreadBindingManager>(), threadBindingsState ??= resolveGlobalSingleton<TelegramThreadBindingsState>(
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(), TELEGRAM_THREAD_BINDINGS_STATE_KEY,
persistQueueByAccountId: new Map<string, Promise<void>>(), () => ({
}), managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
); bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId; persistQueueByAccountId: new Map<string, Promise<void>>(),
const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation; }),
const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId; );
return threadBindingsState;
}
function normalizeDurationMs(raw: unknown, fallback: number): number { function normalizeDurationMs(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) { if (typeof raw !== "number" || !Number.isFinite(raw)) {
@ -168,7 +170,7 @@ function fromSessionBindingInput(params: {
}): TelegramThreadBindingRecord { }): TelegramThreadBindingRecord {
const now = Date.now(); const now = Date.now();
const metadata = params.input.metadata ?? {}; const metadata = params.input.metadata ?? {};
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get( const existing = getThreadBindingsState().bindingsByAccountConversation.get(
resolveBindingKey({ resolveBindingKey({
accountId: params.accountId, accountId: params.accountId,
conversationId: params.input.conversationId, conversationId: params.input.conversationId,
@ -310,7 +312,7 @@ async function persistBindingsToDisk(params: {
version: STORE_VERSION, version: STORE_VERSION,
bindings: bindings:
params.bindings ?? params.bindings ??
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( [...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
(entry) => entry.accountId === params.accountId, (entry) => entry.accountId === params.accountId,
), ),
}; };
@ -322,7 +324,7 @@ async function persistBindingsToDisk(params: {
} }
function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] { function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] {
return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter(
(entry) => entry.accountId === accountId, (entry) => entry.accountId === accountId,
); );
} }
@ -335,16 +337,17 @@ function enqueuePersistBindings(params: {
if (!params.persist) { if (!params.persist) {
return Promise.resolve(); return Promise.resolve();
} }
const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve(); const previous =
getThreadBindingsState().persistQueueByAccountId.get(params.accountId) ?? Promise.resolve();
const next = previous const next = previous
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
await persistBindingsToDisk(params); await persistBindingsToDisk(params);
}); });
PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next); getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next);
void next.finally(() => { void next.finally(() => {
if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) { if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) {
PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId); getThreadBindingsState().persistQueueByAccountId.delete(params.accountId);
} }
}); });
return next; return next;
@ -412,7 +415,7 @@ export function createTelegramThreadBindingManager(
} = {}, } = {},
): TelegramThreadBindingManager { ): TelegramThreadBindingManager {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); const existing = getThreadBindingsState().managersByAccountId.get(accountId);
if (existing) { if (existing) {
return existing; return existing;
} }
@ -430,7 +433,7 @@ export function createTelegramThreadBindingManager(
accountId, accountId,
conversationId: entry.conversationId, conversationId: entry.conversationId,
}); });
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, { getThreadBindingsState().bindingsByAccountConversation.set(key, {
...entry, ...entry,
accountId, accountId,
}); });
@ -448,7 +451,7 @@ export function createTelegramThreadBindingManager(
if (!conversationId) { if (!conversationId) {
return undefined; return undefined;
} }
return BINDINGS_BY_ACCOUNT_CONVERSATION.get( return getThreadBindingsState().bindingsByAccountConversation.get(
resolveBindingKey({ resolveBindingKey({
accountId, accountId,
conversationId, conversationId,
@ -471,7 +474,7 @@ export function createTelegramThreadBindingManager(
return null; return null;
} }
const key = resolveBindingKey({ accountId, conversationId }); const key = resolveBindingKey({ accountId, conversationId });
const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); const existing = getThreadBindingsState().bindingsByAccountConversation.get(key);
if (!existing) { if (!existing) {
return null; return null;
} }
@ -479,7 +482,7 @@ export function createTelegramThreadBindingManager(
...existing, ...existing,
lastActivityAt: normalizeTimestampMs(at ?? Date.now()), lastActivityAt: normalizeTimestampMs(at ?? Date.now()),
}; };
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord); getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord);
persistBindingsSafely({ persistBindingsSafely({
accountId, accountId,
persist: manager.shouldPersistMutations(), persist: manager.shouldPersistMutations(),
@ -494,11 +497,11 @@ export function createTelegramThreadBindingManager(
return null; return null;
} }
const key = resolveBindingKey({ accountId, conversationId }); const key = resolveBindingKey({ accountId, conversationId });
const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null;
if (!removed) { if (!removed) {
return null; return null;
} }
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); getThreadBindingsState().bindingsByAccountConversation.delete(key);
persistBindingsSafely({ persistBindingsSafely({
accountId, accountId,
persist: manager.shouldPersistMutations(), persist: manager.shouldPersistMutations(),
@ -521,7 +524,7 @@ export function createTelegramThreadBindingManager(
accountId, accountId,
conversationId: entry.conversationId, conversationId: entry.conversationId,
}); });
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); getThreadBindingsState().bindingsByAccountConversation.delete(key);
removed.push(entry); removed.push(entry);
} }
if (removed.length > 0) { if (removed.length > 0) {
@ -540,9 +543,9 @@ export function createTelegramThreadBindingManager(
sweepTimer = null; sweepTimer = null;
} }
unregisterSessionBindingAdapter({ channel: "telegram", accountId }); unregisterSessionBindingAdapter({ channel: "telegram", accountId });
const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId); const existingManager = getThreadBindingsState().managersByAccountId.get(accountId);
if (existingManager === manager) { if (existingManager === manager) {
MANAGERS_BY_ACCOUNT_ID.delete(accountId); getThreadBindingsState().managersByAccountId.delete(accountId);
} }
}, },
}; };
@ -574,7 +577,7 @@ export function createTelegramThreadBindingManager(
metadata: input.metadata, metadata: input.metadata,
}, },
}); });
BINDINGS_BY_ACCOUNT_CONVERSATION.set( getThreadBindingsState().bindingsByAccountConversation.set(
resolveBindingKey({ accountId, conversationId }), resolveBindingKey({ accountId, conversationId }),
record, record,
); );
@ -714,14 +717,14 @@ export function createTelegramThreadBindingManager(
sweepTimer.unref?.(); sweepTimer.unref?.();
} }
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); getThreadBindingsState().managersByAccountId.set(accountId, manager);
return manager; return manager;
} }
export function getTelegramThreadBindingManager( export function getTelegramThreadBindingManager(
accountId?: string, accountId?: string,
): TelegramThreadBindingManager | null { ): TelegramThreadBindingManager | null {
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
} }
function updateTelegramBindingsBySessionKey(params: { function updateTelegramBindingsBySessionKey(params: {
@ -741,7 +744,7 @@ function updateTelegramBindingsBySessionKey(params: {
conversationId: entry.conversationId, conversationId: entry.conversationId,
}); });
const next = params.update(entry, now); const next = params.update(entry, now);
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next); getThreadBindingsState().bindingsByAccountConversation.set(key, next);
updated.push(next); updated.push(next);
} }
if (updated.length > 0) { if (updated.length > 0) {
@ -799,12 +802,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: {
export const __testing = { export const __testing = {
async resetTelegramThreadBindingsForTests() { async resetTelegramThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { for (const manager of getThreadBindingsState().managersByAccountId.values()) {
manager.stop(); manager.stop();
} }
await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values()); await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values());
PERSIST_QUEUE_BY_ACCOUNT_ID.clear(); getThreadBindingsState().persistQueueByAccountId.clear();
MANAGERS_BY_ACCOUNT_ID.clear(); getThreadBindingsState().managersByAccountId.clear();
BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); getThreadBindingsState().bindingsByAccountConversation.clear();
}, },
}; };

View File

@ -1,5 +1,6 @@
import fsSync from "node:fs"; import fsSync from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest"; import { vi } from "vitest";
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js"; import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
import { createMockBaileys } from "../../../test/mocks/baileys.js"; import { createMockBaileys } from "../../../test/mocks/baileys.js";
@ -32,6 +33,21 @@ export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG; (globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
} }
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
if (!store) {
const agentId = (opts?.agentId?.trim() || "main").toLowerCase();
return path.join(
process.env.HOME ?? "/tmp",
".openclaw",
"agents",
agentId,
"sessions",
"sessions.json",
);
}
return path.resolve(store.replaceAll("{agentId}", opts?.agentId?.trim() || "main"));
}
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>(); const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
const mockModule = Object.create(null) as Record<string, unknown>; const mockModule = Object.create(null) as Record<string, unknown>;
@ -92,7 +108,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
writable: true, writable: true,
value: actual.resolveStorePath, value:
typeof actual.resolveStorePath === "function"
? actual.resolveStorePath
: resolveStorePathFallback,
}, },
}); });
return mockModule; return mockModule;

View File

@ -43,7 +43,10 @@ export const handlePluginCommand: CommandHandler = async (
to: command.to, to: command.to,
accountId: params.ctx.AccountId ?? undefined, accountId: params.ctx.AccountId ?? undefined,
messageThreadId: 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 { return {

View File

@ -567,6 +567,47 @@ describe("gateway-status command", () => {
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
}); });
it("passes the full caller timeout through to local loopback probes", async () => {
const { runtime } = createRuntimeCapture();
probeGateway.mockClear();
readBestEffortConfig.mockResolvedValueOnce({
gateway: {
mode: "local",
auth: { mode: "token", token: "ltok" },
},
} as never);
await runGatewayStatus(runtime, { timeout: "15000", json: true });
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:18789",
timeoutMs: 15_000,
}),
);
});
it("keeps inactive local loopback probes on the short timeout in remote mode", async () => {
const { runtime } = createRuntimeCapture();
probeGateway.mockClear();
readBestEffortConfig.mockResolvedValueOnce({
gateway: {
mode: "remote",
auth: { mode: "token", token: "ltok" },
remote: {},
},
} as never);
await runGatewayStatus(runtime, { timeout: "15000", json: true });
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:18789",
timeoutMs: 800,
}),
);
});
it("skips invalid ssh-auto discovery targets", async () => { it("skips invalid ssh-auto discovery targets", async () => {
const { runtime } = createRuntimeCapture(); const { runtime } = createRuntimeCapture();
await withEnvAsync({ USER: "steipete" }, async () => { await withEnvAsync({ USER: "steipete" }, async () => {

View File

@ -176,7 +176,7 @@ export async function gatewayStatusCommand(
token: authResolution.token, token: authResolution.token,
password: authResolution.password, password: authResolution.password,
}; };
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target);
const probe = await probeGateway({ const probe = await probeGateway({
url: target.url, url: target.url,
auth, auth,

View File

@ -6,6 +6,7 @@ import {
isScopeLimitedProbeFailure, isScopeLimitedProbeFailure,
renderProbeSummaryLine, renderProbeSummaryLine,
resolveAuthForTarget, resolveAuthForTarget,
resolveProbeBudgetMs,
} from "./helpers.js"; } from "./helpers.js";
describe("extractConfigSummary", () => { describe("extractConfigSummary", () => {
@ -273,3 +274,21 @@ describe("probe reachability classification", () => {
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
}); });
}); });
describe("resolveProbeBudgetMs", () => {
it("lets active local loopback probes use the full caller budget", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000);
expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000);
});
it("keeps inactive local loopback probes on the short cap", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800);
expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500);
});
it("keeps non-local probe caps unchanged", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500);
expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500);
expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000);
});
});

View File

@ -116,14 +116,21 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
return targets; return targets;
} }
export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { export function resolveProbeBudgetMs(
if (kind === "localLoopback") { overallMs: number,
return Math.min(800, overallMs); target: Pick<GatewayStatusTarget, "kind" | "active">,
): number {
switch (target.kind) {
case "localLoopback":
// Active loopback probes should honor the caller budget because local shells/containers
// can legitimately take longer to connect. Inactive loopback probes stay bounded so
// remote-mode status checks do not stall on an expected local miss.
return target.active ? overallMs : Math.min(800, overallMs);
case "sshTunnel":
return Math.min(2_000, overallMs);
default:
return Math.min(1_500, overallMs);
} }
if (kind === "sshTunnel") {
return Math.min(2000, overallMs);
}
return Math.min(1500, overallMs);
} }
export function sanitizeSshTarget(value: unknown): string | null { export function sanitizeSshTarget(value: unknown): string | null {

View File

@ -40,9 +40,15 @@ vi.mock("./client.js", () => ({
GatewayClient: MockGatewayClient, GatewayClient: MockGatewayClient,
})); }));
const { probeGateway } = await import("./probe.js"); const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js");
describe("probeGateway", () => { describe("probeGateway", () => {
it("clamps probe timeout to timer-safe bounds", () => {
expect(clampProbeTimeoutMs(1)).toBe(250);
expect(clampProbeTimeoutMs(2_000)).toBe(2_000);
expect(clampProbeTimeoutMs(3_000_000_000)).toBe(2_147_483_647);
});
it("connects with operator.read scope", async () => { it("connects with operator.read scope", async () => {
const result = await probeGateway({ const result = await probeGateway({
url: "ws://127.0.0.1:18789", url: "ws://127.0.0.1:18789",

View File

@ -29,6 +29,13 @@ export type GatewayProbeResult = {
configSnapshot: unknown; configSnapshot: unknown;
}; };
export const MIN_PROBE_TIMEOUT_MS = 250;
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
export function clampProbeTimeoutMs(timeoutMs: number): number {
return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs));
}
export async function probeGateway(opts: { export async function probeGateway(opts: {
url: string; url: string;
auth?: GatewayProbeAuth; auth?: GatewayProbeAuth;
@ -144,21 +151,18 @@ export async function probeGateway(opts: {
}, },
}); });
const timer = setTimeout( const timer = setTimeout(() => {
() => { settle({
settle({ ok: false,
ok: false, connectLatencyMs,
connectLatencyMs, error: connectError ? `connect failed: ${connectError}` : "timeout",
error: connectError ? `connect failed: ${connectError}` : "timeout", close,
close, health: null,
health: null, status: null,
status: null, presence: null,
presence: null, configSnapshot: null,
configSnapshot: null, });
}); }, clampProbeTimeoutMs(opts.timeoutMs));
},
Math.max(250, opts.timeoutMs),
);
client.start(); client.start();
}); });

View File

@ -3,8 +3,10 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { import {
clearDeviceBootstrapTokens,
DEVICE_BOOTSTRAP_TOKEN_TTL_MS, DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
issueDeviceBootstrapToken, issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken, verifyDeviceBootstrapToken,
} from "./device-bootstrap.js"; } from "./device-bootstrap.js";
@ -15,6 +17,22 @@ function resolveBootstrapPath(baseDir: string): string {
return path.join(baseDir, "devices", "bootstrap.json"); return path.join(baseDir, "devices", "bootstrap.json");
} }
async function verifyBootstrapToken(
baseDir: string,
token: string,
overrides: Partial<Parameters<typeof verifyDeviceBootstrapToken>[0]> = {},
) {
return await verifyDeviceBootstrapToken({
token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
...overrides,
});
}
afterEach(async () => { afterEach(async () => {
vi.useRealTimers(); vi.useRealTimers();
await tempDirs.cleanup(); await tempDirs.cleanup();
@ -47,43 +65,85 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir(); const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({ baseDir });
await expect( await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect( await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
verifyDeviceBootstrapToken({ ok: false,
token: issued.token, reason: "bootstrap_token_invalid",
deviceId: "device-123", });
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); 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 () => { it("keeps the token when required verification fields are blank", async () => {
const baseDir = await createTempDir(); const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({ baseDir });
await expect( await expect(
verifyDeviceBootstrapToken({ verifyBootstrapToken(baseDir, issued.token, {
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: " ", role: " ",
scopes: ["operator.admin"],
baseDir,
}), }),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
@ -95,16 +155,9 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir(); const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({ baseDir });
await expect( await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({
verifyDeviceBootstrapToken({ ok: true,
token: ` ${issued.token} `, });
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
}); });
@ -113,16 +166,10 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir(); const baseDir = await createTempDir();
await issueDeviceBootstrapToken({ baseDir }); await issueDeviceBootstrapToken({ baseDir });
await expect( await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
verifyDeviceBootstrapToken({ ok: false,
token: " ", reason: "bootstrap_token_invalid",
deviceId: "device-123", });
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect( await expect(
verifyDeviceBootstrapToken({ verifyDeviceBootstrapToken({
@ -179,26 +226,11 @@ describe("device bootstrap tokens", () => {
"utf8", "utf8",
); );
await expect( await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
verifyDeviceBootstrapToken({
token: "legacyToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect( await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
verifyDeviceBootstrapToken({ ok: false,
token: "expiredToken", reason: "bootstrap_token_invalid",
deviceId: "device-123", });
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
}); });
}); });

View File

@ -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: { export async function verifyDeviceBootstrapToken(params: {
token: string; token: string;
deviceId: string; deviceId: string;
@ -93,12 +128,13 @@ export async function verifyDeviceBootstrapToken(params: {
if (!providedToken) { if (!providedToken) {
return { ok: false, reason: "bootstrap_token_invalid" }; 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), verifyPairingToken(providedToken, candidate.token),
); );
if (!entry) { if (!found) {
return { ok: false, reason: "bootstrap_token_invalid" }; return { ok: false, reason: "bootstrap_token_invalid" };
} }
const [tokenKey] = found;
const deviceId = params.deviceId.trim(); const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.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 // Bootstrap setup codes are single-use. Consume the record before returning
// success so the same token cannot be replayed to mutate a pending request. // 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); await persistState(state, params.baseDir);
return { ok: true }; return { ok: true };
}); });

View File

@ -1,4 +1,8 @@
// Shared bootstrap/pairing helpers for plugins that provision remote devices. // Shared bootstrap/pairing helpers for plugins that provision remote devices.
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; export {
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
} from "../infra/device-bootstrap.js";

View File

@ -322,7 +322,7 @@ function resolveBindingConversationFromCommand(params: {
from?: string; from?: string;
to?: string; to?: string;
accountId?: string; accountId?: string;
messageThreadId?: number; messageThreadId?: string | number;
}): { }): {
channel: string; channel: string;
accountId: string; accountId: string;

View File

@ -963,7 +963,7 @@ export type PluginCommandContext = {
/** Account id for multi-account channels */ /** Account id for multi-account channels */
accountId?: string; accountId?: string;
/** Thread/topic id if available */ /** Thread/topic id if available */
messageThreadId?: number; messageThreadId?: string | number;
requestConversationBinding: ( requestConversationBinding: (
params?: PluginConversationBindingRequestParams, params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>; ) => Promise<PluginConversationBindingRequestResult>;

View File

@ -31,14 +31,6 @@
"resolvedPath": "extensions/imessage/runtime-api.js", "resolvedPath": "extensions/imessage/runtime-api.js",
"reason": "imports extension-owned file from src/plugins" "reason": "imports extension-owned file from src/plugins"
}, },
{
"file": "src/plugins/runtime/runtime-matrix.ts",
"line": 4,
"kind": "import",
"specifier": "../../../extensions/matrix/runtime-api.js",
"resolvedPath": "extensions/matrix/runtime-api.js",
"reason": "imports extension-owned file from src/plugins"
},
{ {
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
"line": 10, "line": 10,