openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
308 lines (307 loc) • 13 kB
JavaScript
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 };