diff --git a/src/commands/doctor-device-pairing.test.ts b/src/commands/doctor-device-pairing.test.ts new file mode 100644 index 00000000000..8e6cf6ad022 --- /dev/null +++ b/src/commands/doctor-device-pairing.test.ts @@ -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"); + }); +}); diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts new file mode 100644 index 00000000000..940be508f4d --- /dev/null +++ b/src/commands/doctor-device-pairing.ts @@ -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 & { + tokens?: DeviceAuthTokenSummary[]; +}; + +type GatewayDevicePairingPayload = { + pending: DevicePairingPendingRequest[]; + paired: GatewayListedPairedDevice[]; +}; + +type DoctorPairedDevice = Omit & { + tokenSummaries: DeviceAuthTokenSummary[]; +}; + +type DoctorPairingSnapshot = { + pending: DevicePairingPendingRequest[]; + paired: DoctorPairedDevice[]; +}; + +type StoredDeviceIdentity = { + version: 1; + deviceId: string; +}; + +function uniqueStrings(...items: Array): string[] { + const values = new Set(); + 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 { + if (params.healthOk) { + try { + const payload = await callGateway({ + 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, +): 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 { + 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"); +} diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 3d99c976ccb..7831e37c169 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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 { + await noteDevicePairingHealth({ + cfg: ctx.cfg, + healthOk: ctx.healthOk ?? false, + }); +} + async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise { 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",