UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

308 lines (307 loc) 13 kB
import { y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { t as formatCliCommand } from "./command-format-CKGmlpAQ.js"; import { f as normalizeUniqueSingleOrTrimmedStringList } from "./string-normalization-WNUDCpXX.js"; import { c as JsonFileReadError, p as tryReadJsonSync } from "./json-files-2umMHm0W.js"; import { t as sanitizeTerminalText } from "./safe-text-BkQYdJi1.js"; import { o as callGateway } from "./call-B5-GYOlf.js"; import { n as normalizeDeviceAuthScopes } from "./device-auth-C-STNejO.js"; import { t as note } from "./note-BRSJp0UF.js"; import { l as roleScopesAllow } from "./pairing-token-BEUQW6ez.js"; import { c as listApprovedPairedDeviceRoles, g as summarizeDeviceTokens, l as listDevicePairing } from "./device-pairing-Bcmp5CuV.js"; import path from "node:path"; //#region src/commands/doctor-device-pairing.ts /** Doctor diagnostics for pending, paired, and locally cached device auth state. */ function hasNumberVersion(value) { return "version" in value && typeof value.version === "number"; } function isDeviceAuthStoreTokenEntry(value) { return typeof value === "object" && value !== null && "token" in value && typeof value.token === "string" && "role" in value && typeof value.role === "string" && "scopes" in value && Array.isArray(value.scopes) && value.scopes.every((scope) => typeof scope === "string") && "updatedAtMs" in value && typeof value.updatedAtMs === "number"; } function normalizeGatewayPairedDevice(device) { return { ...device, tokenSummaries: device.tokens ?? [] }; } function normalizeLocalPairedDevice(device) { return { ...device, tokenSummaries: summarizeDeviceTokens(device.tokens) ?? [] }; } async function loadDoctorPairingSnapshot(params) { if (params.healthOk) try { const payload = await callGateway({ method: "device.pair.list", timeoutMs: 5e3, config: params.cfg }); return { pending: payload.pending, paired: payload.paired.map((device) => normalizeGatewayPairedDevice(device)) }; } catch {} 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) { return normalizeDeviceAuthScopes(device.approvedScopes ?? device.scopes); } function formatScopes(scopes) { return scopes.length > 0 ? scopes.join(", ") : "none"; } function formatRoles(roles) { return roles.length > 0 ? roles.join(", ") : "none"; } function quoteCliArg(value) { if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; return `'${value.replaceAll("'", "'\\''")}'`; } function formatCliArgs(args) { return formatCliCommand(args.map(quoteCliArg).join(" ")); } function describeDevice(params) { const label = sanitizeTerminalText(params.displayName?.trim() || "") || sanitizeTerminalText(params.clientId?.trim() || ""); return label ? `${label} (${params.deviceId})` : params.deviceId; } function findTokenSummary(device, role) { const normalizedRole = role.trim(); return device.tokenSummaries.find((entry) => entry.role === normalizedRole && !entry.revokedAtMs); } function hasPendingScopeUpgrade(params) { 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 resolvePendingPairingIssue(pending, paired) { const deviceLabel = describeDevice({ deviceId: pending.deviceId, displayName: pending.displayName, clientId: pending.clientId }); const approveCommand = formatCliArgs([ "openclaw", "devices", "approve", pending.requestId ]); const inspectCommand = formatCliArgs([ "openclaw", "devices", "list" ]); if (!paired) return { kind: "first-time", pending, deviceLabel, approveCommand, inspectCommand }; if (paired.publicKey !== pending.publicKey) return { kind: "public-key-repair", pending, deviceLabel, approveCommand, inspectCommand, removeCommand: formatCliArgs([ "openclaw", "devices", "remove", pending.deviceId ]) }; const requestedRoles = normalizeUniqueSingleOrTrimmedStringList([pending.roles, pending.role].flat()); const approvedRoles = listApprovedPairedDeviceRoles(paired); if (requestedRoles.some((role) => !approvedRoles.includes(role))) return { kind: "role-upgrade", pending, deviceLabel, approveCommand, inspectCommand, approvedRoles, requestedRoles }; const approvedScopes = resolveApprovedScopes(paired); const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); if (hasPendingScopeUpgrade({ requestedRoles, pendingScopes: requestedScopes, approvedRoles, approvedScopes })) return { kind: "scope-upgrade", pending, deviceLabel, approveCommand, inspectCommand, approvedScopes, requestedScopes }; return { kind: "repair", pending, deviceLabel, approveCommand, inspectCommand }; } function formatPendingPairingIssue(issue) { switch (issue.kind) { case "first-time": return `- Pending device pairing request ${issue.pending.requestId} for ${issue.deviceLabel}. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`; case "public-key-repair": return `- Pending device repair ${issue.pending.requestId} for ${issue.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 ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.`; case "role-upgrade": return `- Pending role upgrade ${issue.pending.requestId} for ${issue.deviceLabel}: approved roles [${formatRoles(issue.approvedRoles)}], requested roles [${formatRoles(issue.requestedRoles)}]. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`; case "scope-upgrade": return `- Pending scope upgrade ${issue.pending.requestId} for ${issue.deviceLabel}: approved scopes [${formatScopes(issue.approvedScopes)}], requested scopes [${formatScopes(issue.requestedScopes)}]. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`; case "repair": return `- Pending device repair ${issue.pending.requestId} for ${issue.deviceLabel}: the device is already paired, but a new approval is still required before the requested auth can be used. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`; } throw new Error("Unsupported pending pairing issue"); } function collectPendingPairingIssues(snapshot) { const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device])); return snapshot.pending.map((pending) => formatPendingPairingIssue(resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)))); } function collectPairedRecordIssues(snapshot) { const lines = []; 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 = formatCliArgs([ "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) { return tryReadJsonSync(filePath); } function readLocalIdentity(env = process.env) { const identity = readJsonFile(path.join(resolveStateDir(env), "identity", "device.json")); if (!identity || typeof identity !== "object" || !hasNumberVersion(identity) || identity.version !== 1 || !("deviceId" in identity) || typeof identity.deviceId !== "string" || !identity.deviceId.trim()) return null; return { version: 1, deviceId: identity.deviceId }; } function readLocalDeviceAuthStore(env = process.env) { const store = readJsonFile(path.join(resolveStateDir(env), "identity", "device-auth.json")); if (!store || typeof store !== "object" || !hasNumberVersion(store) || store.version !== 1 || !("deviceId" in store) || typeof store.deviceId !== "string" || !store.deviceId.trim() || !("tokens" in store) || typeof store.tokens !== "object" || store.tokens === null) return null; const tokens = {}; for (const [role, entry] of Object.entries(store.tokens)) { if (!isDeviceAuthStoreTokenEntry(entry)) return null; tokens[role] = entry; } return { version: 1, deviceId: store.deviceId, tokens }; } function collectLocalDeviceAuthIssues(snapshot) { 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 = []; const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired)); for (const entry of Object.values(store.tokens)) { const role = entry.role.trim(); if (!role) continue; const pairedToken = findTokenSummary(paired, role); if (!pairedToken) { 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.`); continue; } const rotateCommand = formatCliArgs([ "openclaw", "devices", "rotate", "--device", paired.deviceId, "--role", role ]); 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; } function formatPairingStoreReadIssue(error) { const problem = error.reason === "parse" ? "contains invalid JSON" : "could not be read"; 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.`; } /** * Emits device pairing repair guidance from live gateway state or local pairing files. * * Remote gateways only report through the gateway API; local gateways can fall back to on-disk * pairing state when the gateway is down. */ async function noteDevicePairingHealth(params) { let snapshot; try { snapshot = await loadDoctorPairingSnapshot(params); } catch (error) { if (error instanceof JsonFileReadError) { note(formatPairingStoreReadIssue(error), "Device pairing"); return; } throw error; } if (!snapshot) return; const lines = [ ...collectPendingPairingIssues(snapshot), ...collectPairedRecordIssues(snapshot), ...collectLocalDeviceAuthIssues(snapshot) ]; if (lines.length === 0) return; note(lines.join("\n"), "Device pairing"); } //#endregion export { noteDevicePairingHealth };