diff --git a/src/commands/doctor-device-pairing.test.ts b/src/commands/doctor-device-pairing.test.ts index 85c52406c12..c9ec1e1a43f 100644 --- a/src/commands/doctor-device-pairing.test.ts +++ b/src/commands/doctor-device-pairing.test.ts @@ -60,6 +60,7 @@ function requireRecord(value: unknown, label: string): Record { } describe("noteDevicePairingHealth", () => { + let collectDevicePairingHealthFindings: typeof import("./doctor-device-pairing.js").collectDevicePairingHealthFindings; let noteDevicePairingHealth: typeof import("./doctor-device-pairing.js").noteDevicePairingHealth; async function withApprovedOperatorPairing( @@ -102,7 +103,8 @@ describe("noteDevicePairingHealth", () => { vi.resetModules(); callGatewayMock.mockReset(); noteMock.mockReset(); - ({ noteDevicePairingHealth } = await import("./doctor-device-pairing.js")); + ({ collectDevicePairingHealthFindings, noteDevicePairingHealth } = + await import("./doctor-device-pairing.js")); }); afterEach(() => { @@ -112,7 +114,7 @@ describe("noteDevicePairingHealth", () => { it("warns about pending scope upgrades from local pairing state when the gateway is down", async () => { await withApprovedOperatorPairing(async ({ identity, publicKey }) => { - await requestDevicePairing({ + const pending = await requestDevicePairing({ deviceId: identity.deviceId, publicKey, role: "operator", @@ -134,6 +136,22 @@ describe("noteDevicePairingHealth", () => { expect(message).toContain("operator.admin"); expect(message).toContain("openclaw devices approve"); expect(callGatewayMock).not.toHaveBeenCalled(); + + const findings = await collectDevicePairingHealthFindings({ + cfg: { gateway: { mode: "local" } }, + }); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/device-pairing", + severity: "warning", + path: "devices.pending", + target: identity.deviceId + ":" + pending.request.requestId, + requirement: "scope-upgrade", + message: expect.stringContaining("Pending scope upgrade"), + fixHint: expect.stringContaining("openclaw devices approve"), + }), + ]); + expect(callGatewayMock).not.toHaveBeenCalled(); }); }); @@ -160,6 +178,20 @@ describe("noteDevicePairingHealth", () => { expect(message).toContain("paired.json"); expect(message).toContain("refused to treat it as empty"); expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}"); + + const findings = await collectDevicePairingHealthFindings({ + cfg: { gateway: { mode: "local" } }, + }); + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/device-pairing", + severity: "warning", + path: pairedPath, + requirement: "pairing-store-parse", + message: expect.stringContaining("refused to treat it as empty"), + }), + ]); + expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}"); }, ); }); diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index d41acacf366..140ca15b26c 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -7,6 +7,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { quoteCliArg } from "../cli/quote-cli-arg.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding } from "../flows/health-checks.js"; import { callGateway } from "../gateway/call.js"; import { listApprovedPairedDeviceRoles, @@ -21,6 +22,8 @@ import type { DeviceAuthStore } from "../shared/device-auth.js"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +const DEVICE_PAIRING_CHECK_ID = "core/doctor/device-pairing"; + type GatewayListedPairedDevice = Omit & { tokens?: DeviceAuthTokenSummary[]; }; @@ -81,6 +84,27 @@ type PendingPairingIssue = inspectCommand: string; }; +type PairedRecordIssue = { + kind: + | "missing-operator-scope-baseline" + | "missing-active-role-token" + | "token-outside-approved-scope"; + deviceId: string; + deviceLabel: string; + role?: string; + message: string; + fixHint?: string; +}; + +type LocalDeviceAuthIssue = { + kind: "local-role-no-longer-approved" | "local-token-stale" | "local-scopes-mismatch"; + deviceId: string; + deviceLabel: string; + role: string; + message: string; + fixHint: string; +}; + type StoredDeviceIdentity = { version: 1; deviceId: string; @@ -306,17 +330,15 @@ function formatPendingPairingIssue(issue: PendingPairingIssue): string { throw new Error("Unsupported pending pairing issue"); } -function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): string[] { +function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): PendingPairingIssue[] { const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device])); return snapshot.pending.map((pending) => - formatPendingPairingIssue( - resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)), - ), + resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)), ); } -function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] { - const lines: string[] = []; +function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): PairedRecordIssue[] { + const issues: PairedRecordIssue[] = []; for (const device of snapshot.paired) { const deviceLabel = describeDevice({ deviceId: device.deviceId, @@ -326,9 +348,12 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] { const approvedRoles = listApprovedPairedDeviceRoles(device); const approvedScopes = resolveApprovedScopes(device); if (approvedRoles.includes("operator") && approvedScopes.length === 0) { - lines.push( - `- Paired device ${deviceLabel} is missing its approved operator scope baseline. Scope upgrades can get stuck in pairing-required until the device repairs or is re-approved.`, - ); + issues.push({ + kind: "missing-operator-scope-baseline", + deviceId: device.deviceId, + deviceLabel, + message: `Paired device ${deviceLabel} is missing its approved operator scope baseline. Scope upgrades can get stuck in pairing-required until the device repairs or is re-approved.`, + }); } for (const role of approvedRoles) { const token = findTokenSummary(device, role); @@ -342,9 +367,14 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] { role, ]); if (!token) { - lines.push( - `- Paired device ${deviceLabel} has no active ${role} device token even though the role is approved. This commonly ends in pairing-required or device-token-mismatch. Rotate a fresh token with ${rotateCommand}.`, - ); + issues.push({ + kind: "missing-active-role-token", + deviceId: device.deviceId, + deviceLabel, + role, + message: `Paired device ${deviceLabel} has no active ${role} device token even though the role is approved. This commonly ends in pairing-required or device-token-mismatch. Rotate a fresh token with ${rotateCommand}.`, + fixHint: `Rotate a fresh token with ${rotateCommand}.`, + }); continue; } if ( @@ -355,13 +385,22 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] { allowedScopes: approvedScopes, }) ) { - lines.push( - `- Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`, - ); + issues.push({ + kind: "token-outside-approved-scope", + deviceId: device.deviceId, + deviceLabel, + role, + message: `Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`, + fixHint: `Rotate it with ${rotateCommand}.`, + }); } } } - return lines; + return issues; +} + +function formatPairedRecordIssue(issue: PairedRecordIssue): string { + return `- ${issue.message}`; } function readJsonFile(filePath: string): unknown { @@ -419,7 +458,7 @@ function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceA }; } -function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] { +function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDeviceAuthIssue[] { const identity = readLocalIdentity(); const store = readLocalDeviceAuthStore(); if (!identity || !store || store.deviceId !== identity.deviceId) { @@ -434,7 +473,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] displayName: paired.displayName, clientId: paired.clientId, }); - const lines: string[] = []; + const issues: LocalDeviceAuthIssue[] = []; const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired)); for (const entry of Object.values(store.tokens)) { const role = entry.role.trim(); @@ -446,9 +485,14 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] if (approvedRoles.has(role)) { continue; } - lines.push( - `- Local cached ${role} device auth for ${deviceLabel} no longer has a matching active gateway token, and that role is no longer approved for this device. Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`, - ); + issues.push({ + kind: "local-role-no-longer-approved", + deviceId: paired.deviceId, + deviceLabel, + role, + message: `Local cached ${role} device auth for ${deviceLabel} no longer has a matching active gateway token, and that role is no longer approved for this device. Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`, + fixHint: `Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`, + }); continue; } const rotateCommand = formatCliArgs([ @@ -463,20 +507,34 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] const gatewayIssuedAtMs = pairedToken.rotatedAtMs ?? pairedToken.createdAtMs; // Local device auth survives gateway restarts; compare timestamps to catch stale cached tokens. if (entry.updatedAtMs < gatewayIssuedAtMs) { - lines.push( - `- Local cached ${role} device token for ${deviceLabel} predates the gateway rotation. This is a stale device-token pattern and can fail with device token mismatch. Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`, - ); + issues.push({ + kind: "local-token-stale", + deviceId: paired.deviceId, + deviceLabel, + role, + message: `Local cached ${role} device token for ${deviceLabel} predates the gateway rotation. This is a stale device-token pattern and can fail with device token mismatch. Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`, + fixHint: `Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`, + }); continue; } const cachedScopes = normalizeDeviceAuthScopes(entry.scopes); const pairedScopes = normalizeDeviceAuthScopes(pairedToken.scopes); if (cachedScopes.join("\n") !== pairedScopes.join("\n")) { - lines.push( - `- Local cached ${role} device scopes for ${deviceLabel} differ from the gateway record. Cached scopes [${formatScopes(cachedScopes)}], gateway scopes [${formatScopes(pairedScopes)}]. Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`, - ); + issues.push({ + kind: "local-scopes-mismatch", + deviceId: paired.deviceId, + deviceLabel, + role, + message: `Local cached ${role} device scopes for ${deviceLabel} differ from the gateway record. Cached scopes [${formatScopes(cachedScopes)}], gateway scopes [${formatScopes(pairedScopes)}]. Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`, + fixHint: `Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`, + }); } } - return lines; + return issues; +} + +function formatLocalDeviceAuthIssue(issue: LocalDeviceAuthIssue): string { + return `- ${issue.message}`; } function formatPairingStoreReadIssue(error: JsonFileReadError): string { @@ -484,6 +542,87 @@ function formatPairingStoreReadIssue(error: JsonFileReadError): string { return `- Device pairing store ${error.filePath} ${problem}. OpenClaw refused to treat it as empty to avoid overwriting approved pairings. Fix the JSON or file permissions, or move it aside and re-pair devices.`; } +function stripListMarker(message: string): string { + return message.startsWith("- ") ? message.slice(2) : message; +} + +function pendingPairingIssueToHealthFinding(issue: PendingPairingIssue): HealthFinding { + const fixHint = + issue.kind === "public-key-repair" + ? `Remove the stale record with ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.` + : `Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`; + return { + checkId: DEVICE_PAIRING_CHECK_ID, + severity: "warning", + message: stripListMarker(formatPendingPairingIssue(issue)), + path: "devices.pending", + target: `${issue.pending.deviceId}:${issue.pending.requestId}`, + requirement: issue.kind, + fixHint, + }; +} + +function pairedRecordIssueToHealthFinding(issue: PairedRecordIssue): HealthFinding { + return { + checkId: DEVICE_PAIRING_CHECK_ID, + severity: "warning", + message: issue.message, + path: "devices.paired", + target: issue.role ? `${issue.deviceId}:${issue.role}` : issue.deviceId, + requirement: issue.kind, + ...(issue.fixHint ? { fixHint: issue.fixHint } : {}), + }; +} + +function localDeviceAuthIssueToHealthFinding(issue: LocalDeviceAuthIssue): HealthFinding { + return { + checkId: DEVICE_PAIRING_CHECK_ID, + severity: "warning", + message: issue.message, + path: "identity.device-auth", + target: `${issue.deviceId}:${issue.role}`, + requirement: issue.kind, + fixHint: issue.fixHint, + }; +} + +function pairingStoreReadIssueToHealthFinding(error: JsonFileReadError): HealthFinding { + return { + checkId: DEVICE_PAIRING_CHECK_ID, + severity: "warning", + message: stripListMarker(formatPairingStoreReadIssue(error)), + path: error.filePath, + requirement: `pairing-store-${error.reason}`, + fixHint: "Fix the JSON or file permissions, or move the store aside and re-pair devices.", + }; +} + +export async function collectDevicePairingHealthFindings(params: { + cfg: OpenClawConfig; + healthOk?: boolean; +}): Promise { + let snapshot: DoctorPairingSnapshot | null; + try { + snapshot = await loadDoctorPairingSnapshot({ + cfg: params.cfg, + healthOk: params.healthOk ?? false, + }); + } catch (error) { + if (error instanceof JsonFileReadError) { + return [pairingStoreReadIssueToHealthFinding(error)]; + } + throw error; + } + if (!snapshot) { + return []; + } + return [ + ...collectPendingPairingIssues(snapshot).map(pendingPairingIssueToHealthFinding), + ...collectPairedRecordIssues(snapshot).map(pairedRecordIssueToHealthFinding), + ...collectLocalDeviceAuthIssues(snapshot).map(localDeviceAuthIssueToHealthFinding), + ]; +} + /** * Emits device pairing repair guidance from live gateway state or local pairing files. * @@ -508,9 +647,9 @@ export async function noteDevicePairingHealth(params: { return; } const lines = [ - ...collectPendingPairingIssues(snapshot), - ...collectPairedRecordIssues(snapshot), - ...collectLocalDeviceAuthIssues(snapshot), + ...collectPendingPairingIssues(snapshot).map(formatPendingPairingIssue), + ...collectPairedRecordIssues(snapshot).map(formatPairedRecordIssue), + ...collectLocalDeviceAuthIssues(snapshot).map(formatLocalDeviceAuthIssue), ]; if (lines.length === 0) { return; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 53fa93f93f5..bdcdf626074 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -76,6 +76,7 @@ const mocks = vi.hoisted(() => ({ gatherDaemonStatus: vi.fn(), noteWorkspaceStatus: vi.fn(), collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]), + collectDevicePairingHealthFindings: vi.fn(async () => []), applyWizardMetadata: vi.fn((cfg: unknown) => cfg), logConfigUpdated: vi.fn(), isRecord: vi.fn( @@ -272,6 +273,11 @@ vi.mock("../commands/doctor-workspace-status.js", () => ({ collectWorkspaceStatusHealthFindings: mocks.collectWorkspaceStatusHealthFindings, })); +vi.mock("../commands/doctor-device-pairing.js", () => ({ + collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings, + noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("../commands/onboard-helpers.js", () => ({ applyWizardMetadata: mocks.applyWizardMetadata, randomToken: vi.fn(() => "generated-gateway-token"), @@ -444,6 +450,8 @@ describe("doctor health contributions", () => { mocks.noteWorkspaceStatus.mockReset(); mocks.collectWorkspaceStatusHealthFindings.mockReset(); mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]); + mocks.collectDevicePairingHealthFindings.mockReset(); + mocks.collectDevicePairingHealthFindings.mockResolvedValue([]); }); afterEach(() => { @@ -1160,6 +1168,7 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/session-snapshots"); expect(contributionIds).toContain("core/doctor/plugin-registry"); expect(contributionIds).toContain("core/doctor/configured-plugin-installs"); + expect(contributionIds).toContain("core/doctor/device-pairing"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); }); @@ -1256,6 +1265,39 @@ describe("doctor health contributions", () => { expect(findings).toEqual([]); }); + it("keeps device pairing opt-in for default lint selection", async () => { + const contributionChecks = await resolveDoctorContributionHealthChecks(); + const devicePairingCheck = contributionChecks.find( + (check) => check.id === "core/doctor/device-pairing", + ); + expect(devicePairingCheck).toMatchObject({ defaultEnabled: false }); + expect(devicePairingCheck).toBeDefined(); + + const ctx = { + cfg: { gateway: { mode: "local" } }, + mode: "lint", + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + } as const; + const checks = [devicePairingCheck!]; + + await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ + checksRun: 0, + checksSkipped: 1, + }); + expect(mocks.collectDevicePairingHealthFindings).not.toHaveBeenCalled(); + + await expect( + runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/device-pairing"] }), + ).resolves.toMatchObject({ + checksRun: 1, + checksSkipped: 0, + }); + expect(mocks.collectDevicePairingHealthFindings).toHaveBeenCalledWith({ + cfg: ctx.cfg, + healthOk: false, + }); + }); + it("uses legacy run when a contribution also declares structured health", async () => { const legacyRun = vi.fn(); const healthChecks = { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index eec857a3f52..6c25a6fa192 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1755,6 +1755,16 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:device-pairing", label: "Device pairing", + healthChecks: { + id: "core/doctor/device-pairing", + description: "Device pairing requests and stale device-auth records are findings.", + defaultEnabled: false, + async detect(ctx) { + const { collectDevicePairingHealthFindings } = + await import("../commands/doctor-device-pairing.js"); + return await collectDevicePairingHealthFindings({ cfg: ctx.cfg, healthOk: false }); + }, + }, run: runDevicePairingHealth, }), createDoctorHealthContribution({