fix(doctor): harden pairing health notes

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:17:42 +05:30
parent f19e3ab298
commit a1b4ef9b2f
2 changed files with 86 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import {
import {
approveDevicePairing,
requestDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { withEnvAsync } from "../test-utils/env.js";
@@ -183,4 +184,81 @@ describe("noteDevicePairingHealth", () => {
expect(noteMock).toHaveBeenCalledTimes(1);
expect(String(noteMock.mock.calls[0]?.[0] ?? "")).toContain("req-gateway-1");
});
it("sanitizes device labels before printing doctor notes", async () => {
callGatewayMock.mockResolvedValue({
pending: [
{
requestId: "req-gateway-1",
deviceId: "device-gateway-1",
publicKey: "pubkey",
role: "operator",
roles: ["operator"],
scopes: ["operator.admin"],
clientId: "control-ui\tclient",
clientMode: "webchat",
displayName: "\u001b[2Kbad\nname",
ts: 1,
isRepair: false,
},
],
paired: [],
});
await noteDevicePairingHealth({
cfg: { gateway: { mode: "remote" } },
healthOk: true,
});
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
expect(message).toContain("bad\\nname");
expect(message).not.toContain("\u001b");
expect(message).not.toContain("control-ui\tclient");
});
it("does not duplicate missing-token warnings when local cache exists for an approved role", 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"],
});
await revokeDeviceToken({
deviceId: identity.deviceId,
role: "operator",
});
await noteDevicePairingHealth({
cfg: { gateway: { mode: "local" } },
healthOk: false,
});
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
expect(message).toContain("has no active operator device token");
expect(message).not.toContain("no longer has a matching active gateway token");
},
);
});
});
});

View File

@@ -16,6 +16,7 @@ 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";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
type GatewayListedPairedDevice = Omit<PairedDevice, "tokens" | "approvedScopes"> & {
tokens?: DeviceAuthTokenSummary[];
@@ -188,7 +189,9 @@ function describeDevice(params: {
displayName?: string;
clientId?: string;
}): string {
const label = params.displayName?.trim() || params.clientId?.trim();
const label =
sanitizeTerminalText(params.displayName?.trim() || "") ||
sanitizeTerminalText(params.clientId?.trim() || "");
return label ? `${label} (${params.deviceId})` : params.deviceId;
}
@@ -447,6 +450,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
clientId: paired.clientId,
});
const lines: string[] = [];
const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired));
for (const entry of Object.values(store.tokens)) {
const role = entry.role.trim();
if (!role) {
@@ -457,6 +461,9 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
);
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. Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`,
);