Device pair: dedupe QR pairing flow

This commit is contained in:
ImLukeF 2026-03-21 11:57:03 +11:00
parent 7647951dd3
commit a0c340292c
No known key found for this signature in database
5 changed files with 308 additions and 380 deletions

View File

@ -289,6 +289,17 @@ public final class OpenClawChatViewModel {
stopReason: message.stopReason)
}
private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
}
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !role.isEmpty else { return nil }
@ -298,15 +309,7 @@ public final class OpenClawChatViewModel {
return String(format: "%.3f", value)
}()
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let contentFingerprint = Self.messageContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
@ -319,15 +322,7 @@ public final class OpenClawChatViewModel {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard role == "user" else { return nil }
let contentFingerprint = message.content.map { item in
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
}.joined(separator: "\\u{001E}")
let contentFingerprint = Self.messageContentFingerprint(for: message)
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {

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(
transport: TestChatTransport,
runId: String,
@ -454,18 +476,10 @@ extension TestChatTransportState {
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello from mac webchat")
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: "main",
state: "final",
message: nil,
errorMessage: nil)))
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 {
@ -485,18 +499,10 @@ extension TestChatTransportState {
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello from mac webchat")
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: "main",
state: "final",
message: nil,
errorMessage: nil)))
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 {
@ -527,18 +533,10 @@ extension TestChatTransportState {
let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "hello from mac webchat")
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: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await sendMessageAndEmitFinal(
transport: transport,
vm: vm,
text: "hello from mac webchat")
try await waitUntil("canonical refresh keeps one user message") {
await MainActor.run {

View File

@ -71,6 +71,37 @@ function createApi(params?: {
}) 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",
@ -103,15 +134,7 @@ describe("device-pair /pair qr", () => {
});
it("returns an inline QR image for webchat surfaces", async () => {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
const command = registerPairCommand();
const result = await command?.handler(createCommandContext({ channel: "webchat" }));
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
@ -135,9 +158,9 @@ describe("device-pair /pair qr", () => {
messageThreadId: 271,
},
expectedTarget: "123",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expect(opts.messageThreadId).toBe(271);
expectedOpts: {
accountId: "default",
messageThreadId: 271,
},
},
{
@ -150,8 +173,8 @@ describe("device-pair /pair qr", () => {
accountId: "default",
},
expectedTarget: "user:123",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expectedOpts: {
accountId: "default",
},
},
{
@ -165,9 +188,9 @@ describe("device-pair /pair qr", () => {
messageThreadId: "1234567890.000001",
},
expectedTarget: "user:U123",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expect(opts.threadTs).toBe("1234567890.000001");
expectedOpts: {
accountId: "default",
threadTs: "1234567890.000001",
},
},
{
@ -180,8 +203,8 @@ describe("device-pair /pair qr", () => {
accountId: "default",
},
expectedTarget: "signal:+15551234567",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expectedOpts: {
accountId: "default",
},
},
{
@ -194,8 +217,8 @@ describe("device-pair /pair qr", () => {
accountId: "default",
},
expectedTarget: "+15551234567",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expectedOpts: {
accountId: "default",
},
},
{
@ -208,9 +231,9 @@ describe("device-pair /pair qr", () => {
accountId: "default",
},
expectedTarget: "+15551234567",
assertOpts: (opts: Record<string, unknown>) => {
expect(opts.accountId).toBe("default");
expect(opts.verbose).toBe(false);
expectedOpts: {
accountId: "default",
verbose: false,
},
},
])("sends $label a real QR image attachment", async (testCase) => {
@ -221,21 +244,9 @@ describe("device-pair /pair qr", () => {
}
return { messageId: "1" };
});
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
runtime: {
channel: {
[testCase.runtimeKey]: {
[testCase.sendKey]: sendMessage,
},
},
} as unknown as OpenClawPluginApi["runtime"],
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
const command = registerPairCommand({
runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage),
});
const result = await command?.handler(createCommandContext(testCase.ctx));
@ -255,7 +266,7 @@ describe("device-pair /pair qr", () => {
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!)]);
testCase.assertOpts(opts);
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.");
@ -274,21 +285,9 @@ describe("device-pair /pair qr", () => {
});
const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed"));
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
runtime: {
channel: {
discord: {
sendMessageDiscord: sendMessage,
},
},
} as unknown as OpenClawPluginApi["runtime"],
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
const command = registerPairCommand({
runtime: createChannelRuntime("discord", "sendMessageDiscord", sendMessage),
});
const result = await command?.handler(
createCommandContext({
@ -306,15 +305,7 @@ describe("device-pair /pair qr", () => {
});
it("falls back to the setup code instead of ASCII when the channel cannot send media", async () => {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
const command = registerPairCommand();
const result = await command?.handler(
createCommandContext({
channel: "msteams",
@ -329,15 +320,7 @@ describe("device-pair /pair qr", () => {
});
it("supports invalidating unused setup codes", async () => {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
createApi({
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
const command = registerPairCommand();
const result = await command?.handler(
createCommandContext({
args: "cleanup",

View File

@ -74,6 +74,76 @@ type QrCommandContext = {
messageThreadId?: string | number;
};
type QrChannelSender = {
resolveSend: (api: OpenClawPluginApi) => ((...args: unknown[]) => Promise<unknown>) | 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 {
const candidate = raw.trim();
if (!candidate) {
@ -326,26 +396,60 @@ function encodeSetupCode(payload: SetupPayload): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function buildPairingFlowLines(stepTwo: string): string[] {
return [
"1) Open the iOS app → Settings → Gateway",
`2) ${stepTwo}`,
"3) Back here, run /pair approve",
"4) If this code leaks or you are done, run /pair cleanup",
];
}
function buildSecurityNoticeLines(params: {
kind: "setup code" | "QR code";
expiresAtMs: number;
markdown?: boolean;
}): string[] {
const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup";
const securityPrefix = params.markdown ? "- " : "";
const importantLine = params.markdown
? `**Important:** Run ${cleanupCommand} after pairing finishes.`
: `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`;
return [
`${securityPrefix}Security: single-use bootstrap token`,
`${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
"",
importantLine,
`If this ${params.kind} leaks, run ${cleanupCommand} immediately.`,
];
}
function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] {
return autoNotifyArmed
? [
"After scanning, wait here for the pairing request ping.",
"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 {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code below and tap Connect",
"3) Back here, run /pair approve",
"4) If this code leaks or you are done, run /pair cleanup",
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"Security: single-use bootstrap token",
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}`,
"",
"IMPORTANT: After pairing finishes, run /pair cleanup.",
"If this setup code leaks, run /pair cleanup immediately.",
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs: payload.expiresAtMs,
}),
].join("\n");
}
@ -353,43 +457,30 @@ function formatSetupInstructions(expiresAtMs: number): string {
return [
"Pairing setup code generated.",
"",
"1) Open the iOS app → Settings → Gateway",
"2) Paste the setup code from my next message and tap Connect",
"3) Back here, run /pair approve",
"4) If this code leaks or you are done, run /pair cleanup",
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
"",
"Security: single-use bootstrap token",
`Expires: ${formatDurationMinutes(expiresAtMs)}`,
"",
"IMPORTANT: After pairing finishes, run /pair cleanup.",
"If this setup code leaks, run /pair cleanup immediately.",
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs,
}),
].join("\n");
}
function formatQrInfoLines(params: {
function buildQrInfoLines(params: {
payload: SetupPayload;
authLabel: string;
autoNotifyArmed: boolean;
expiresAtMs: number;
}) {
}): string[] {
return [
`Gateway: ${params.payload.url}`,
`Auth: ${params.authLabel}`,
"Security: single-use bootstrap token",
`Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
...buildSecurityNoticeLines({
kind: "QR code",
expiresAtMs: params.expiresAtMs,
}),
"",
"IMPORTANT: After pairing finishes, run /pair cleanup.",
"If this QR code leaks, run /pair cleanup immediately.",
"",
params.autoNotifyArmed
? "After scanning, wait here for the pairing request ping."
: "After scanning, run `/pair approve` to complete pairing.",
...(params.autoNotifyArmed
? [
"Ill auto-ping here when the pairing request arrives, then auto-disable.",
"If the ping does not arrive, run `/pair approve latest` manually.",
]
: []),
...buildQrFollowUpLines(params.autoNotifyArmed),
"",
"If your camera still wont lock on, run `/pair` for a pasteable setup code.",
];
@ -401,30 +492,24 @@ function formatQrInfoMarkdown(params: {
autoNotifyArmed: boolean;
expiresAtMs: number;
}): string {
const guidance = params.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."];
const [gatewayLine, authLine, ...rest] = buildQrInfoLines(params);
return [
`- Gateway: ${params.payload.url}`,
`- Auth: ${params.authLabel}`,
"- Security: single-use bootstrap token",
`- Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
"",
"**Important:** Run `/pair cleanup` after pairing finishes.",
"If this QR code leaks, run `/pair cleanup` immediately.",
"",
...guidance,
"",
"If your camera still wont lock on, run `/pair` for a pasteable setup code.",
`- ${gatewayLine}`,
`- ${authLine}`,
...rest.map((line) =>
line.startsWith("Security:") || line.startsWith("Expires:")
? `- ${line}`
: line === "IMPORTANT: After pairing finishes, run /pair cleanup."
? "**Important:** Run `/pair cleanup` after pairing finishes."
: line === "If this QR code leaks, run /pair cleanup immediately."
? "If this QR code leaks, run `/pair cleanup` immediately."
: line,
),
].join("\n");
}
function canSendQrPngToChannel(channel: string): boolean {
return ["telegram", "discord", "slack", "signal", "imessage", "whatsapp"].includes(channel);
return channel in QR_CHANNEL_SENDERS;
}
function resolveQrReplyTarget(ctx: QrCommandContext): string {
@ -457,90 +542,25 @@ async function sendQrPngToSupportedChannel(params: {
}): Promise<boolean> {
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
const accountId = params.ctx.accountId?.trim() || undefined;
switch (params.ctx.channel) {
case "telegram": {
const send = params.api.runtime?.channel?.telegram?.sendMessageTelegram;
if (!send) {
return false;
}
await send(params.target, params.caption, {
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(typeof params.ctx.messageThreadId === "number"
? { messageThreadId: params.ctx.messageThreadId }
: {}),
...(accountId ? { accountId } : {}),
});
return true;
}
case "discord": {
const send = params.api.runtime?.channel?.discord?.sendMessageDiscord;
if (!send) {
return false;
}
await send(params.target, params.caption, {
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
});
return true;
}
case "slack": {
const send = params.api.runtime?.channel?.slack?.sendMessageSlack;
if (!send) {
return false;
}
await send(params.target, params.caption, {
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(params.ctx.messageThreadId != null
? { threadTs: String(params.ctx.messageThreadId) }
: {}),
...(accountId ? { accountId } : {}),
});
return true;
}
case "signal": {
const send = params.api.runtime?.channel?.signal?.sendMessageSignal;
if (!send) {
return false;
}
await send(params.target, params.caption, {
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
});
return true;
}
case "imessage": {
const send = params.api.runtime?.channel?.imessage?.sendMessageIMessage;
if (!send) {
return false;
}
await send(params.target, params.caption, {
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
});
return true;
}
case "whatsapp": {
const send = params.api.runtime?.channel?.whatsapp?.sendMessageWhatsApp;
if (!send) {
return false;
}
await send(params.target, params.caption, {
verbose: false,
mediaUrl: params.qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
});
return true;
}
default:
return false;
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({
@ -658,7 +678,7 @@ export default definePluginEntry({
let payload = await issueSetupPayload(urlResult.url);
let setupCode = encodeSetupCode(payload);
const infoLines = formatQrInfoLines({
const infoLines = buildQrInfoLines({
payload,
authLabel,
autoNotifyArmed,

View File

@ -17,6 +17,22 @@ function resolveBootstrapPath(baseDir: string): string {
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 () => {
vi.useRealTimers();
await tempDirs.cleanup();
@ -49,27 +65,12 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
});
@ -82,27 +83,15 @@ describe("device bootstrap tokens", () => {
await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 });
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
await expect(
verifyDeviceBootstrapToken({
token: first.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
token: second.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
});
it("revokes a specific bootstrap token", async () => {
@ -114,27 +103,12 @@ describe("device bootstrap tokens", () => {
removed: true,
});
await expect(
verifyDeviceBootstrapToken({
token: first.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
token: second.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
});
it("consumes bootstrap tokens by the persisted map key", async () => {
@ -158,16 +132,7 @@ describe("device bootstrap tokens", () => {
"utf8",
);
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}");
});
@ -177,13 +142,8 @@ describe("device bootstrap tokens", () => {
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-123",
publicKey: "public-key-123",
verifyBootstrapToken(baseDir, issued.token, {
role: " ",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
@ -195,16 +155,9 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: ` ${issued.token} `,
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({
ok: true,
});
await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");
});
@ -213,16 +166,10 @@ describe("device bootstrap tokens", () => {
const baseDir = await createTempDir();
await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: " ",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
await expect(
verifyDeviceBootstrapToken({
@ -279,26 +226,11 @@ describe("device bootstrap tokens", () => {
"utf8",
);
await expect(
verifyDeviceBootstrapToken({
token: "legacyToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: "expiredToken",
deviceId: "device-123",
publicKey: "public-key-123",
role: "operator.admin",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
ok: false,
reason: "bootstrap_token_invalid",
});
});
});