fix(doctor): report device pairing auth drift

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:02:23 +05:30
parent b414c8b863
commit 451b37ece1
3 changed files with 580 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { storeDeviceAuthToken } from "../infra/device-auth-store.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withTempDir } from "../test-utils/temp-dir.js";
const callGatewayMock = vi.hoisted(() => vi.fn());
const noteMock = vi.hoisted(() => vi.fn());
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
}));
vi.mock("../terminal/note.js", () => ({
note: (...args: unknown[]) => noteMock(...args),
}));
describe("noteDevicePairingHealth", () => {
let noteDevicePairingHealth: typeof import("./doctor-device-pairing.js").noteDevicePairingHealth;
beforeEach(async () => {
vi.resetModules();
callGatewayMock.mockReset();
noteMock.mockReset();
({ noteDevicePairingHealth } = await import("./doctor-device-pairing.js"));
});
afterEach(() => {
callGatewayMock.mockReset();
noteMock.mockReset();
});
it("warns about pending scope upgrades from local pairing state when the gateway is down", 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"],
});
await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
role: "operator",
scopes: ["operator.admin"],
clientId: "control-ui",
clientMode: "webchat",
displayName: "Dashboard",
});
await noteDevicePairingHealth({
cfg: { gateway: { mode: "local" } },
healthOk: false,
});
expect(noteMock).toHaveBeenCalledTimes(1);
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
expect(noteMock.mock.calls[0]?.[1]).toBe("Device pairing");
expect(message).toContain("Pending scope upgrade");
expect(message).toContain("operator.admin");
expect(message).toContain("openclaw devices approve");
expect(callGatewayMock).not.toHaveBeenCalled();
},
);
});
});
it("warns when the local cached device token predates the gateway rotation", 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"],
});
const deviceAuthPath = path.join(stateDir, "identity", "device-auth.json");
const store = JSON.parse(await fs.readFile(deviceAuthPath, "utf8")) as {
version: 1;
deviceId: string;
tokens: Record<
string,
{ token: string; role: string; scopes: string[]; updatedAtMs: number }
>;
};
store.tokens.operator.updatedAtMs = 1;
await fs.writeFile(deviceAuthPath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
const rotated = await rotateDeviceToken({
deviceId: identity.deviceId,
role: "operator",
});
expect(rotated.ok).toBe(true);
await noteDevicePairingHealth({
cfg: { gateway: { mode: "local" } },
healthOk: false,
});
expect(noteMock).toHaveBeenCalledTimes(1);
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
expect(message).toContain("stale device-token pattern");
expect(message).toContain("openclaw devices rotate");
},
);
});
});
it("uses gateway device pairing state when the gateway is healthy", async () => {
callGatewayMock.mockResolvedValue({
pending: [
{
requestId: "req-gateway-1",
deviceId: "device-gateway-1",
publicKey: "pubkey",
role: "operator",
roles: ["operator"],
scopes: ["operator.admin"],
clientId: "control-ui",
clientMode: "webchat",
displayName: "Dashboard",
ts: 1,
isRepair: false,
},
],
paired: [],
});
await noteDevicePairingHealth({
cfg: { gateway: { mode: "remote" } },
healthOk: true,
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "device.pair.list",
}),
);
expect(noteMock).toHaveBeenCalledTimes(1);
expect(String(noteMock.mock.calls[0]?.[0] ?? "")).toContain("req-gateway-1");
});
});

View File

@@ -0,0 +1,381 @@
import fs from "node:fs";
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
import {
listApprovedPairedDeviceRoles,
listDevicePairing,
summarizeDeviceTokens,
type DeviceAuthTokenSummary,
type DevicePairingPendingRequest,
type PairedDevice,
} from "../infra/device-pairing.js";
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";
type GatewayListedPairedDevice = Omit<PairedDevice, "tokens" | "approvedScopes"> & {
tokens?: DeviceAuthTokenSummary[];
};
type GatewayDevicePairingPayload = {
pending: DevicePairingPendingRequest[];
paired: GatewayListedPairedDevice[];
};
type DoctorPairedDevice = Omit<PairedDevice, "tokens"> & {
tokenSummaries: DeviceAuthTokenSummary[];
};
type DoctorPairingSnapshot = {
pending: DevicePairingPendingRequest[];
paired: DoctorPairedDevice[];
};
type StoredDeviceIdentity = {
version: 1;
deviceId: string;
};
function uniqueStrings(...items: Array<string | string[] | undefined>): string[] {
const values = new Set<string>();
for (const item of items) {
if (!item) {
continue;
}
if (Array.isArray(item)) {
for (const value of item) {
const trimmed = value.trim();
if (trimmed) {
values.add(trimmed);
}
}
continue;
}
const trimmed = item.trim();
if (trimmed) {
values.add(trimmed);
}
}
return [...values];
}
function normalizeGatewayPairedDevice(device: GatewayListedPairedDevice): DoctorPairedDevice {
return {
...device,
tokenSummaries: device.tokens ?? [],
};
}
function normalizeLocalPairedDevice(device: PairedDevice): DoctorPairedDevice {
return {
...device,
tokenSummaries: summarizeDeviceTokens(device.tokens) ?? [],
};
}
async function loadDoctorPairingSnapshot(params: {
cfg: OpenClawConfig;
healthOk: boolean;
}): Promise<DoctorPairingSnapshot | null> {
if (params.healthOk) {
try {
const payload = await callGateway<GatewayDevicePairingPayload>({
method: "device.pair.list",
timeoutMs: 5_000,
config: params.cfg,
});
return {
pending: payload.pending,
paired: payload.paired.map((device) => normalizeGatewayPairedDevice(device)),
};
} catch {
// Gateway health already reported separately. Fall back to local pairing
// state when doctor is running against a local gateway.
}
}
if (params.cfg.gateway?.mode === "remote") {
return null;
}
const local = await listDevicePairing();
return {
pending: local.pending,
paired: local.paired.map((device) => normalizeLocalPairedDevice(device)),
};
}
function resolveApprovedScopes(
device: Pick<DoctorPairedDevice, "approvedScopes" | "scopes">,
): string[] {
return normalizeDeviceAuthScopes(device.approvedScopes ?? device.scopes);
}
function formatScopes(scopes: string[]): string {
return scopes.length > 0 ? scopes.join(", ") : "none";
}
function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(", ") : "none";
}
function describeDevice(params: {
deviceId: string;
displayName?: string;
clientId?: string;
}): string {
const label = params.displayName?.trim() || params.clientId?.trim();
return label ? `${label} (${params.deviceId})` : params.deviceId;
}
function findTokenSummary(
device: DoctorPairedDevice,
role: string,
): DeviceAuthTokenSummary | undefined {
const normalizedRole = role.trim();
return device.tokenSummaries.find((entry) => entry.role === normalizedRole && !entry.revokedAtMs);
}
function hasPendingScopeUpgrade(params: {
requestedRoles: string[];
pendingScopes: string[];
approvedRoles: string[];
approvedScopes: string[];
}): boolean {
for (const role of params.requestedRoles) {
if (!params.approvedRoles.includes(role)) {
continue;
}
const requestedForRole = params.pendingScopes.filter((scope) =>
role === "operator" ? scope.startsWith("operator.") : !scope.startsWith("operator."),
);
if (requestedForRole.length === 0) {
continue;
}
if (
!roleScopesAllow({
role,
requestedScopes: requestedForRole,
allowedScopes: params.approvedScopes,
})
) {
return true;
}
}
return false;
}
function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): string[] {
const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device]));
const lines: string[] = [];
for (const pending of snapshot.pending) {
const deviceLabel = describeDevice({
deviceId: pending.deviceId,
displayName: pending.displayName,
clientId: pending.clientId,
});
const approveCommand = formatCliCommand(`openclaw devices approve ${pending.requestId}`);
const inspectCommand = formatCliCommand("openclaw devices list");
const paired = pairedByDeviceId.get(pending.deviceId);
if (!paired) {
lines.push(
`- Pending device pairing request ${pending.requestId} for ${deviceLabel}. Review with ${inspectCommand}, then approve with ${approveCommand}.`,
);
continue;
}
if (paired.publicKey !== pending.publicKey) {
const removeCommand = formatCliCommand(`openclaw devices remove ${pending.deviceId}`);
lines.push(
`- Pending device repair ${pending.requestId} for ${deviceLabel}: the current device identity no longer matches the approved pairing record. This commonly loops on pairing-required for an already paired device. Remove the stale record with ${removeCommand}, then rerun ${inspectCommand} and approve with ${approveCommand}.`,
);
continue;
}
const requestedRoles = uniqueStrings(pending.roles, pending.role);
const approvedRoles = listApprovedPairedDeviceRoles(paired);
const approvedScopes = resolveApprovedScopes(paired);
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
const roleUpgrade = requestedRoles.some((role) => !approvedRoles.includes(role));
if (roleUpgrade) {
lines.push(
`- Pending role upgrade ${pending.requestId} for ${deviceLabel}: approved roles [${formatRoles(approvedRoles)}], requested roles [${formatRoles(requestedRoles)}]. Review with ${inspectCommand}, then approve with ${approveCommand}.`,
);
continue;
}
if (
hasPendingScopeUpgrade({
requestedRoles,
pendingScopes: requestedScopes,
approvedRoles,
approvedScopes,
})
) {
lines.push(
`- Pending scope upgrade ${pending.requestId} for ${deviceLabel}: approved scopes [${formatScopes(approvedScopes)}], requested scopes [${formatScopes(requestedScopes)}]. Review with ${inspectCommand}, then approve with ${approveCommand}.`,
);
continue;
}
lines.push(
`- Pending device repair ${pending.requestId} for ${deviceLabel}: the device is already paired, but a new approval is still required before the requested auth can be used. Review with ${inspectCommand}, then approve with ${approveCommand}.`,
);
}
return lines;
}
function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
const lines: string[] = [];
for (const device of snapshot.paired) {
const deviceLabel = describeDevice({
deviceId: device.deviceId,
displayName: device.displayName,
clientId: device.clientId,
});
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.`,
);
}
for (const role of approvedRoles) {
const token = findTokenSummary(device, role);
const rotateCommand = formatCliCommand(
`openclaw devices rotate --device ${device.deviceId} --role ${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}.`,
);
continue;
}
if (
token.scopes.length > 0 &&
!roleScopesAllow({
role,
requestedScopes: token.scopes,
allowedScopes: approvedScopes,
})
) {
lines.push(
`- Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
);
}
}
}
return lines;
}
function readJsonFile(filePath: string): unknown {
try {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): StoredDeviceIdentity | null {
const filePath = path.join(resolveStateDir(env), "identity", "device.json");
const identity = readJsonFile(filePath);
if (
!identity ||
typeof identity !== "object" ||
!("deviceId" in identity) ||
typeof identity.deviceId !== "string" ||
!identity.deviceId.trim()
) {
return null;
}
return identity;
}
function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceAuthStore | null {
const filePath = path.join(resolveStateDir(env), "identity", "device-auth.json");
const store = readJsonFile(filePath);
if (
!store ||
typeof store !== "object" ||
!("deviceId" in store) ||
typeof store.deviceId !== "string" ||
!store.deviceId.trim() ||
!("tokens" in store) ||
typeof store.tokens !== "object" ||
store.tokens === null
) {
return null;
}
return store;
}
function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] {
const identity = readLocalIdentity();
const store = readLocalDeviceAuthStore();
if (!identity || !store || store.deviceId !== identity.deviceId) {
return [];
}
const paired = snapshot.paired.find((device) => device.deviceId === identity.deviceId);
if (!paired) {
return [];
}
const deviceLabel = describeDevice({
deviceId: paired.deviceId,
displayName: paired.displayName,
clientId: paired.clientId,
});
const lines: string[] = [];
for (const entry of Object.values(store.tokens)) {
const role = entry.role.trim();
if (!role) {
continue;
}
const rotateCommand = formatCliCommand(
`openclaw devices rotate --device ${paired.deviceId} --role ${role}`,
);
const pairedToken = findTokenSummary(paired, role);
if (!pairedToken) {
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}.`,
);
continue;
}
const gatewayIssuedAtMs = pairedToken.rotatedAtMs ?? pairedToken.createdAtMs;
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}.`,
);
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}.`,
);
}
}
return lines;
}
export async function noteDevicePairingHealth(params: {
cfg: OpenClawConfig;
healthOk: boolean;
}): Promise<void> {
const snapshot = await loadDoctorPairingSnapshot(params);
if (!snapshot) {
return;
}
const lines = [
...collectPendingPairingIssues(snapshot),
...collectPairedRecordIssues(snapshot),
...collectLocalDeviceAuthIssues(snapshot),
];
if (lines.length === 0) {
return;
}
note(lines.join("\n"), "Device pairing");
}

View File

@@ -16,6 +16,7 @@ import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-
import { noteClaudeCliHealth } from "../commands/doctor-claude-cli.js";
import { doctorShellCompletion } from "../commands/doctor-completion.js";
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
import { noteDevicePairingHealth } from "../commands/doctor-device-pairing.js";
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
import { checkGatewayHealth, probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
import {
@@ -425,6 +426,13 @@ async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext):
await noteMemoryRecallHealth(ctx.cfg);
}
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await noteDevicePairingHealth({
cfg: ctx.cfg,
healthOk: ctx.healthOk ?? false,
});
}
async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise<void> {
await maybeRepairGatewayDaemon({
cfg: ctx.cfg,
@@ -597,6 +605,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Memory search",
run: runMemorySearchHealthContribution,
}),
createDoctorHealthContribution({
id: "doctor:device-pairing",
label: "Device pairing",
run: runDevicePairingHealth,
}),
createDoctorHealthContribution({
id: "doctor:gateway-daemon",
label: "Gateway daemon",