doctor: expose device pairing findings (#97366)

This commit is contained in:
Gio Della-Libera
2026-06-30 20:34:05 -07:00
committed by GitHub
parent 180a970ac0
commit a1cddbdb57
4 changed files with 256 additions and 33 deletions

View File

@@ -60,6 +60,7 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
}
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}");
},
);
});

View File

@@ -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<PairedDevice, "tokens" | "approvedScopes"> & {
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<HealthFinding[]> {
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;

View File

@@ -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 = {

View File

@@ -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({