Skip to content

Commit eff0d3c

Browse files
committed
doctor: expose device pairing findings
1 parent 4ac5cf8 commit eff0d3c

4 files changed

Lines changed: 256 additions & 33 deletions

File tree

src/commands/doctor-device-pairing.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
6060
}
6161

6262
describe("noteDevicePairingHealth", () => {
63+
let collectDevicePairingHealthFindings: typeof import("./doctor-device-pairing.js").collectDevicePairingHealthFindings;
6364
let noteDevicePairingHealth: typeof import("./doctor-device-pairing.js").noteDevicePairingHealth;
6465

6566
async function withApprovedOperatorPairing(
@@ -102,7 +103,8 @@ describe("noteDevicePairingHealth", () => {
102103
vi.resetModules();
103104
callGatewayMock.mockReset();
104105
noteMock.mockReset();
105-
({ noteDevicePairingHealth } = await import("./doctor-device-pairing.js"));
106+
({ collectDevicePairingHealthFindings, noteDevicePairingHealth } =
107+
await import("./doctor-device-pairing.js"));
106108
});
107109

108110
afterEach(() => {
@@ -112,7 +114,7 @@ describe("noteDevicePairingHealth", () => {
112114

113115
it("warns about pending scope upgrades from local pairing state when the gateway is down", async () => {
114116
await withApprovedOperatorPairing(async ({ identity, publicKey }) => {
115-
await requestDevicePairing({
117+
const pending = await requestDevicePairing({
116118
deviceId: identity.deviceId,
117119
publicKey,
118120
role: "operator",
@@ -134,6 +136,22 @@ describe("noteDevicePairingHealth", () => {
134136
expect(message).toContain("operator.admin");
135137
expect(message).toContain("openclaw devices approve");
136138
expect(callGatewayMock).not.toHaveBeenCalled();
139+
140+
const findings = await collectDevicePairingHealthFindings({
141+
cfg: { gateway: { mode: "local" } },
142+
});
143+
expect(findings).toEqual([
144+
expect.objectContaining({
145+
checkId: "core/doctor/device-pairing",
146+
severity: "warning",
147+
path: "devices.pending",
148+
target: identity.deviceId + ":" + pending.request.requestId,
149+
requirement: "scope-upgrade",
150+
message: expect.stringContaining("Pending scope upgrade"),
151+
fixHint: expect.stringContaining("openclaw devices approve"),
152+
}),
153+
]);
154+
expect(callGatewayMock).not.toHaveBeenCalled();
137155
});
138156
});
139157

@@ -160,6 +178,20 @@ describe("noteDevicePairingHealth", () => {
160178
expect(message).toContain("paired.json");
161179
expect(message).toContain("refused to treat it as empty");
162180
expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}");
181+
182+
const findings = await collectDevicePairingHealthFindings({
183+
cfg: { gateway: { mode: "local" } },
184+
});
185+
expect(findings).toEqual([
186+
expect.objectContaining({
187+
checkId: "core/doctor/device-pairing",
188+
severity: "warning",
189+
path: pairedPath,
190+
requirement: "pairing-store-parse",
191+
message: expect.stringContaining("refused to treat it as empty"),
192+
}),
193+
]);
194+
expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}");
163195
},
164196
);
165197
});

src/commands/doctor-device-pairing.ts

Lines changed: 170 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { formatCliCommand } from "../cli/command-format.js";
77
import { quoteCliArg } from "../cli/quote-cli-arg.js";
88
import { resolveStateDir } from "../config/paths.js";
99
import type { OpenClawConfig } from "../config/types.openclaw.js";
10+
import type { HealthFinding } from "../flows/health-checks.js";
1011
import { callGateway } from "../gateway/call.js";
1112
import {
1213
listApprovedPairedDeviceRoles,
@@ -21,6 +22,8 @@ import type { DeviceAuthStore } from "../shared/device-auth.js";
2122
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
2223
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
2324

25+
const DEVICE_PAIRING_CHECK_ID = "core/doctor/device-pairing";
26+
2427
type GatewayListedPairedDevice = Omit<PairedDevice, "tokens" | "approvedScopes"> & {
2528
tokens?: DeviceAuthTokenSummary[];
2629
};
@@ -81,6 +84,27 @@ type PendingPairingIssue =
8184
inspectCommand: string;
8285
};
8386

87+
type PairedRecordIssue = {
88+
kind:
89+
| "missing-operator-scope-baseline"
90+
| "missing-active-role-token"
91+
| "token-outside-approved-scope";
92+
deviceId: string;
93+
deviceLabel: string;
94+
role?: string;
95+
message: string;
96+
fixHint?: string;
97+
};
98+
99+
type LocalDeviceAuthIssue = {
100+
kind: "local-role-no-longer-approved" | "local-token-stale" | "local-scopes-mismatch";
101+
deviceId: string;
102+
deviceLabel: string;
103+
role: string;
104+
message: string;
105+
fixHint: string;
106+
};
107+
84108
type StoredDeviceIdentity = {
85109
version: 1;
86110
deviceId: string;
@@ -306,17 +330,15 @@ function formatPendingPairingIssue(issue: PendingPairingIssue): string {
306330
throw new Error("Unsupported pending pairing issue");
307331
}
308332

309-
function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): string[] {
333+
function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): PendingPairingIssue[] {
310334
const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device]));
311335
return snapshot.pending.map((pending) =>
312-
formatPendingPairingIssue(
313-
resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)),
314-
),
336+
resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)),
315337
);
316338
}
317339

318-
function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
319-
const lines: string[] = [];
340+
function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): PairedRecordIssue[] {
341+
const issues: PairedRecordIssue[] = [];
320342
for (const device of snapshot.paired) {
321343
const deviceLabel = describeDevice({
322344
deviceId: device.deviceId,
@@ -326,9 +348,12 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
326348
const approvedRoles = listApprovedPairedDeviceRoles(device);
327349
const approvedScopes = resolveApprovedScopes(device);
328350
if (approvedRoles.includes("operator") && approvedScopes.length === 0) {
329-
lines.push(
330-
`- 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.`,
331-
);
351+
issues.push({
352+
kind: "missing-operator-scope-baseline",
353+
deviceId: device.deviceId,
354+
deviceLabel,
355+
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.`,
356+
});
332357
}
333358
for (const role of approvedRoles) {
334359
const token = findTokenSummary(device, role);
@@ -342,9 +367,14 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
342367
role,
343368
]);
344369
if (!token) {
345-
lines.push(
346-
`- 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}.`,
347-
);
370+
issues.push({
371+
kind: "missing-active-role-token",
372+
deviceId: device.deviceId,
373+
deviceLabel,
374+
role,
375+
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}.`,
376+
fixHint: `Rotate a fresh token with ${rotateCommand}.`,
377+
});
348378
continue;
349379
}
350380
if (
@@ -355,13 +385,22 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
355385
allowedScopes: approvedScopes,
356386
})
357387
) {
358-
lines.push(
359-
`- Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
360-
);
388+
issues.push({
389+
kind: "token-outside-approved-scope",
390+
deviceId: device.deviceId,
391+
deviceLabel,
392+
role,
393+
message: `Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
394+
fixHint: `Rotate it with ${rotateCommand}.`,
395+
});
361396
}
362397
}
363398
}
364-
return lines;
399+
return issues;
400+
}
401+
402+
function formatPairedRecordIssue(issue: PairedRecordIssue): string {
403+
return `- ${issue.message}`;
365404
}
366405

367406
function readJsonFile(filePath: string): unknown {
@@ -419,7 +458,7 @@ function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceA
419458
};
420459
}
421460

422-
function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] {
461+
function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDeviceAuthIssue[] {
423462
const identity = readLocalIdentity();
424463
const store = readLocalDeviceAuthStore();
425464
if (!identity || !store || store.deviceId !== identity.deviceId) {
@@ -434,7 +473,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
434473
displayName: paired.displayName,
435474
clientId: paired.clientId,
436475
});
437-
const lines: string[] = [];
476+
const issues: LocalDeviceAuthIssue[] = [];
438477
const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired));
439478
for (const entry of Object.values(store.tokens)) {
440479
const role = entry.role.trim();
@@ -446,9 +485,14 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
446485
if (approvedRoles.has(role)) {
447486
continue;
448487
}
449-
lines.push(
450-
`- 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.`,
451-
);
488+
issues.push({
489+
kind: "local-role-no-longer-approved",
490+
deviceId: paired.deviceId,
491+
deviceLabel,
492+
role,
493+
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.`,
494+
fixHint: `Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`,
495+
});
452496
continue;
453497
}
454498
const rotateCommand = formatCliArgs([
@@ -463,27 +507,122 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
463507
const gatewayIssuedAtMs = pairedToken.rotatedAtMs ?? pairedToken.createdAtMs;
464508
// Local device auth survives gateway restarts; compare timestamps to catch stale cached tokens.
465509
if (entry.updatedAtMs < gatewayIssuedAtMs) {
466-
lines.push(
467-
`- 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}.`,
468-
);
510+
issues.push({
511+
kind: "local-token-stale",
512+
deviceId: paired.deviceId,
513+
deviceLabel,
514+
role,
515+
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}.`,
516+
fixHint: `Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`,
517+
});
469518
continue;
470519
}
471520
const cachedScopes = normalizeDeviceAuthScopes(entry.scopes);
472521
const pairedScopes = normalizeDeviceAuthScopes(pairedToken.scopes);
473522
if (cachedScopes.join("\n") !== pairedScopes.join("\n")) {
474-
lines.push(
475-
`- 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}.`,
476-
);
523+
issues.push({
524+
kind: "local-scopes-mismatch",
525+
deviceId: paired.deviceId,
526+
deviceLabel,
527+
role,
528+
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}.`,
529+
fixHint: `Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`,
530+
});
477531
}
478532
}
479-
return lines;
533+
return issues;
534+
}
535+
536+
function formatLocalDeviceAuthIssue(issue: LocalDeviceAuthIssue): string {
537+
return `- ${issue.message}`;
480538
}
481539

482540
function formatPairingStoreReadIssue(error: JsonFileReadError): string {
483541
const problem = error.reason === "parse" ? "contains invalid JSON" : "could not be read";
484542
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.`;
485543
}
486544

545+
function stripListMarker(message: string): string {
546+
return message.startsWith("- ") ? message.slice(2) : message;
547+
}
548+
549+
function pendingPairingIssueToHealthFinding(issue: PendingPairingIssue): HealthFinding {
550+
const fixHint =
551+
issue.kind === "public-key-repair"
552+
? `Remove the stale record with ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.`
553+
: `Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
554+
return {
555+
checkId: DEVICE_PAIRING_CHECK_ID,
556+
severity: "warning",
557+
message: stripListMarker(formatPendingPairingIssue(issue)),
558+
path: "devices.pending",
559+
target: `${issue.pending.deviceId}:${issue.pending.requestId}`,
560+
requirement: issue.kind,
561+
fixHint,
562+
};
563+
}
564+
565+
function pairedRecordIssueToHealthFinding(issue: PairedRecordIssue): HealthFinding {
566+
return {
567+
checkId: DEVICE_PAIRING_CHECK_ID,
568+
severity: "warning",
569+
message: issue.message,
570+
path: "devices.paired",
571+
target: issue.role ? `${issue.deviceId}:${issue.role}` : issue.deviceId,
572+
requirement: issue.kind,
573+
...(issue.fixHint ? { fixHint: issue.fixHint } : {}),
574+
};
575+
}
576+
577+
function localDeviceAuthIssueToHealthFinding(issue: LocalDeviceAuthIssue): HealthFinding {
578+
return {
579+
checkId: DEVICE_PAIRING_CHECK_ID,
580+
severity: "warning",
581+
message: issue.message,
582+
path: "identity.device-auth",
583+
target: `${issue.deviceId}:${issue.role}`,
584+
requirement: issue.kind,
585+
fixHint: issue.fixHint,
586+
};
587+
}
588+
589+
function pairingStoreReadIssueToHealthFinding(error: JsonFileReadError): HealthFinding {
590+
return {
591+
checkId: DEVICE_PAIRING_CHECK_ID,
592+
severity: "warning",
593+
message: stripListMarker(formatPairingStoreReadIssue(error)),
594+
path: error.filePath,
595+
requirement: `pairing-store-${error.reason}`,
596+
fixHint: "Fix the JSON or file permissions, or move the store aside and re-pair devices.",
597+
};
598+
}
599+
600+
export async function collectDevicePairingHealthFindings(params: {
601+
cfg: OpenClawConfig;
602+
healthOk?: boolean;
603+
}): Promise<HealthFinding[]> {
604+
let snapshot: DoctorPairingSnapshot | null;
605+
try {
606+
snapshot = await loadDoctorPairingSnapshot({
607+
cfg: params.cfg,
608+
healthOk: params.healthOk ?? false,
609+
});
610+
} catch (error) {
611+
if (error instanceof JsonFileReadError) {
612+
return [pairingStoreReadIssueToHealthFinding(error)];
613+
}
614+
throw error;
615+
}
616+
if (!snapshot) {
617+
return [];
618+
}
619+
return [
620+
...collectPendingPairingIssues(snapshot).map(pendingPairingIssueToHealthFinding),
621+
...collectPairedRecordIssues(snapshot).map(pairedRecordIssueToHealthFinding),
622+
...collectLocalDeviceAuthIssues(snapshot).map(localDeviceAuthIssueToHealthFinding),
623+
];
624+
}
625+
487626
/**
488627
* Emits device pairing repair guidance from live gateway state or local pairing files.
489628
*
@@ -508,9 +647,9 @@ export async function noteDevicePairingHealth(params: {
508647
return;
509648
}
510649
const lines = [
511-
...collectPendingPairingIssues(snapshot),
512-
...collectPairedRecordIssues(snapshot),
513-
...collectLocalDeviceAuthIssues(snapshot),
650+
...collectPendingPairingIssues(snapshot).map(formatPendingPairingIssue),
651+
...collectPairedRecordIssues(snapshot).map(formatPairedRecordIssue),
652+
...collectLocalDeviceAuthIssues(snapshot).map(formatLocalDeviceAuthIssue),
514653
];
515654
if (lines.length === 0) {
516655
return;

0 commit comments

Comments
 (0)