From a1b4ef9b2f67cd2022871b72a99b0cae875ef147 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 20 Apr 2026 11:17:42 +0530 Subject: [PATCH] fix(doctor): harden pairing health notes --- src/commands/doctor-device-pairing.test.ts | 78 ++++++++++++++++++++++ src/commands/doctor-device-pairing.ts | 9 ++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor-device-pairing.test.ts b/src/commands/doctor-device-pairing.test.ts index 8e6cf6ad022..764261ce66f 100644 --- a/src/commands/doctor-device-pairing.test.ts +++ b/src/commands/doctor-device-pairing.test.ts @@ -9,6 +9,7 @@ import { import { approveDevicePairing, requestDevicePairing, + revokeDeviceToken, rotateDeviceToken, } from "../infra/device-pairing.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -183,4 +184,81 @@ describe("noteDevicePairingHealth", () => { expect(noteMock).toHaveBeenCalledTimes(1); expect(String(noteMock.mock.calls[0]?.[0] ?? "")).toContain("req-gateway-1"); }); + + it("sanitizes device labels before printing doctor notes", async () => { + callGatewayMock.mockResolvedValue({ + pending: [ + { + requestId: "req-gateway-1", + deviceId: "device-gateway-1", + publicKey: "pubkey", + role: "operator", + roles: ["operator"], + scopes: ["operator.admin"], + clientId: "control-ui\tclient", + clientMode: "webchat", + displayName: "\u001b[2Kbad\nname", + ts: 1, + isRepair: false, + }, + ], + paired: [], + }); + + await noteDevicePairingHealth({ + cfg: { gateway: { mode: "remote" } }, + healthOk: true, + }); + + const message = String(noteMock.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("bad\\nname"); + expect(message).not.toContain("\u001b"); + expect(message).not.toContain("control-ui\tclient"); + }); + + it("does not duplicate missing-token warnings when local cache exists for an approved role", async () => { + await withTempDir("openclaw-doctor-device-pairing-", async (stateDir) => { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }, + async () => { + const identity = loadOrCreateDeviceIdentity(); + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const initial = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + role: "operator", + scopes: ["operator.read"], + clientId: "control-ui", + clientMode: "webchat", + displayName: "Dashboard", + }); + await approveDevicePairing(initial.request.requestId, { + callerScopes: ["operator.read"], + }); + storeDeviceAuthToken({ + deviceId: identity.deviceId, + role: "operator", + token: "stale-local-token", + scopes: ["operator.read"], + }); + await revokeDeviceToken({ + deviceId: identity.deviceId, + role: "operator", + }); + + await noteDevicePairingHealth({ + cfg: { gateway: { mode: "local" } }, + healthOk: false, + }); + + const message = String(noteMock.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("has no active operator device token"); + expect(message).not.toContain("no longer has a matching active gateway token"); + }, + ); + }); + }); }); diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index f1b150372e5..73a2bca9976 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -16,6 +16,7 @@ import type { DeviceAuthStore } from "../shared/device-auth.js"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { note } from "../terminal/note.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; type GatewayListedPairedDevice = Omit & { tokens?: DeviceAuthTokenSummary[]; @@ -188,7 +189,9 @@ function describeDevice(params: { displayName?: string; clientId?: string; }): string { - const label = params.displayName?.trim() || params.clientId?.trim(); + const label = + sanitizeTerminalText(params.displayName?.trim() || "") || + sanitizeTerminalText(params.clientId?.trim() || ""); return label ? `${label} (${params.deviceId})` : params.deviceId; } @@ -447,6 +450,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] clientId: paired.clientId, }); const lines: string[] = []; + const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired)); for (const entry of Object.values(store.tokens)) { const role = entry.role.trim(); if (!role) { @@ -457,6 +461,9 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] ); const pairedToken = findTokenSummary(paired, role); if (!pairedToken) { + if (approvedRoles.has(role)) { + continue; + } lines.push( `- Local cached ${role} device auth for ${deviceLabel} no longer has a matching active gateway token. Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`, );