mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 07:03:33 +00:00
doctor: expose device pairing findings (#97366)
This commit is contained in:
@@ -60,6 +60,7 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("noteDevicePairingHealth", () => {
|
||||
let collectDevicePairingHealthFindings: typeof import("./doctor-device-pairing.js").collectDevicePairingHealthFindings;
|
||||
let noteDevicePairingHealth: typeof import("./doctor-device-pairing.js").noteDevicePairingHealth;
|
||||
|
||||
async function withApprovedOperatorPairing(
|
||||
@@ -102,7 +103,8 @@ describe("noteDevicePairingHealth", () => {
|
||||
vi.resetModules();
|
||||
callGatewayMock.mockReset();
|
||||
noteMock.mockReset();
|
||||
({ noteDevicePairingHealth } = await import("./doctor-device-pairing.js"));
|
||||
({ collectDevicePairingHealthFindings, noteDevicePairingHealth } =
|
||||
await import("./doctor-device-pairing.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -112,7 +114,7 @@ describe("noteDevicePairingHealth", () => {
|
||||
|
||||
it("warns about pending scope upgrades from local pairing state when the gateway is down", async () => {
|
||||
await withApprovedOperatorPairing(async ({ identity, publicKey }) => {
|
||||
await requestDevicePairing({
|
||||
const pending = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
role: "operator",
|
||||
@@ -134,6 +136,22 @@ describe("noteDevicePairingHealth", () => {
|
||||
expect(message).toContain("operator.admin");
|
||||
expect(message).toContain("openclaw devices approve");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
|
||||
const findings = await collectDevicePairingHealthFindings({
|
||||
cfg: { gateway: { mode: "local" } },
|
||||
});
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/device-pairing",
|
||||
severity: "warning",
|
||||
path: "devices.pending",
|
||||
target: identity.deviceId + ":" + pending.request.requestId,
|
||||
requirement: "scope-upgrade",
|
||||
message: expect.stringContaining("Pending scope upgrade"),
|
||||
fixHint: expect.stringContaining("openclaw devices approve"),
|
||||
}),
|
||||
]);
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +178,20 @@ describe("noteDevicePairingHealth", () => {
|
||||
expect(message).toContain("paired.json");
|
||||
expect(message).toContain("refused to treat it as empty");
|
||||
expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}");
|
||||
|
||||
const findings = await collectDevicePairingHealthFindings({
|
||||
cfg: { gateway: { mode: "local" } },
|
||||
});
|
||||
expect(findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/device-pairing",
|
||||
severity: "warning",
|
||||
path: pairedPath,
|
||||
requirement: "pairing-store-parse",
|
||||
message: expect.stringContaining("refused to treat it as empty"),
|
||||
}),
|
||||
]);
|
||||
expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { quoteCliArg } from "../cli/quote-cli-arg.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HealthFinding } from "../flows/health-checks.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import {
|
||||
listApprovedPairedDeviceRoles,
|
||||
@@ -21,6 +22,8 @@ import type { DeviceAuthStore } from "../shared/device-auth.js";
|
||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
|
||||
const DEVICE_PAIRING_CHECK_ID = "core/doctor/device-pairing";
|
||||
|
||||
type GatewayListedPairedDevice = Omit<PairedDevice, "tokens" | "approvedScopes"> & {
|
||||
tokens?: DeviceAuthTokenSummary[];
|
||||
};
|
||||
@@ -81,6 +84,27 @@ type PendingPairingIssue =
|
||||
inspectCommand: string;
|
||||
};
|
||||
|
||||
type PairedRecordIssue = {
|
||||
kind:
|
||||
| "missing-operator-scope-baseline"
|
||||
| "missing-active-role-token"
|
||||
| "token-outside-approved-scope";
|
||||
deviceId: string;
|
||||
deviceLabel: string;
|
||||
role?: string;
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
};
|
||||
|
||||
type LocalDeviceAuthIssue = {
|
||||
kind: "local-role-no-longer-approved" | "local-token-stale" | "local-scopes-mismatch";
|
||||
deviceId: string;
|
||||
deviceLabel: string;
|
||||
role: string;
|
||||
message: string;
|
||||
fixHint: string;
|
||||
};
|
||||
|
||||
type StoredDeviceIdentity = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
@@ -306,17 +330,15 @@ function formatPendingPairingIssue(issue: PendingPairingIssue): string {
|
||||
throw new Error("Unsupported pending pairing issue");
|
||||
}
|
||||
|
||||
function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): PendingPairingIssue[] {
|
||||
const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device]));
|
||||
return snapshot.pending.map((pending) =>
|
||||
formatPendingPairingIssue(
|
||||
resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)),
|
||||
),
|
||||
resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): PairedRecordIssue[] {
|
||||
const issues: PairedRecordIssue[] = [];
|
||||
for (const device of snapshot.paired) {
|
||||
const deviceLabel = describeDevice({
|
||||
deviceId: device.deviceId,
|
||||
@@ -326,9 +348,12 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
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.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "missing-operator-scope-baseline",
|
||||
deviceId: device.deviceId,
|
||||
deviceLabel,
|
||||
message: `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);
|
||||
@@ -342,9 +367,14 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
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}.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "missing-active-role-token",
|
||||
deviceId: device.deviceId,
|
||||
deviceLabel,
|
||||
role,
|
||||
message: `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}.`,
|
||||
fixHint: `Rotate a fresh token with ${rotateCommand}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -355,13 +385,22 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
allowedScopes: approvedScopes,
|
||||
})
|
||||
) {
|
||||
lines.push(
|
||||
`- Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "token-outside-approved-scope",
|
||||
deviceId: device.deviceId,
|
||||
deviceLabel,
|
||||
role,
|
||||
message: `Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
|
||||
fixHint: `Rotate it with ${rotateCommand}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatPairedRecordIssue(issue: PairedRecordIssue): string {
|
||||
return `- ${issue.message}`;
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
@@ -419,7 +458,7 @@ function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceA
|
||||
};
|
||||
}
|
||||
|
||||
function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[] {
|
||||
function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDeviceAuthIssue[] {
|
||||
const identity = readLocalIdentity();
|
||||
const store = readLocalDeviceAuthStore();
|
||||
if (!identity || !store || store.deviceId !== identity.deviceId) {
|
||||
@@ -434,7 +473,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
|
||||
displayName: paired.displayName,
|
||||
clientId: paired.clientId,
|
||||
});
|
||||
const lines: string[] = [];
|
||||
const issues: LocalDeviceAuthIssue[] = [];
|
||||
const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired));
|
||||
for (const entry of Object.values(store.tokens)) {
|
||||
const role = entry.role.trim();
|
||||
@@ -446,9 +485,14 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
|
||||
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.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "local-role-no-longer-approved",
|
||||
deviceId: paired.deviceId,
|
||||
deviceLabel,
|
||||
role,
|
||||
message: `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.`,
|
||||
fixHint: `Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const rotateCommand = formatCliArgs([
|
||||
@@ -463,20 +507,34 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
|
||||
const gatewayIssuedAtMs = pairedToken.rotatedAtMs ?? pairedToken.createdAtMs;
|
||||
// Local device auth survives gateway restarts; compare timestamps to catch stale cached tokens.
|
||||
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}.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "local-token-stale",
|
||||
deviceId: paired.deviceId,
|
||||
deviceLabel,
|
||||
role,
|
||||
message: `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}.`,
|
||||
fixHint: `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}.`,
|
||||
);
|
||||
issues.push({
|
||||
kind: "local-scopes-mismatch",
|
||||
deviceId: paired.deviceId,
|
||||
deviceLabel,
|
||||
role,
|
||||
message: `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}.`,
|
||||
fixHint: `Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatLocalDeviceAuthIssue(issue: LocalDeviceAuthIssue): string {
|
||||
return `- ${issue.message}`;
|
||||
}
|
||||
|
||||
function formatPairingStoreReadIssue(error: JsonFileReadError): string {
|
||||
@@ -484,6 +542,87 @@ function formatPairingStoreReadIssue(error: JsonFileReadError): string {
|
||||
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.`;
|
||||
}
|
||||
|
||||
function stripListMarker(message: string): string {
|
||||
return message.startsWith("- ") ? message.slice(2) : message;
|
||||
}
|
||||
|
||||
function pendingPairingIssueToHealthFinding(issue: PendingPairingIssue): HealthFinding {
|
||||
const fixHint =
|
||||
issue.kind === "public-key-repair"
|
||||
? `Remove the stale record with ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.`
|
||||
: `Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
|
||||
return {
|
||||
checkId: DEVICE_PAIRING_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: stripListMarker(formatPendingPairingIssue(issue)),
|
||||
path: "devices.pending",
|
||||
target: `${issue.pending.deviceId}:${issue.pending.requestId}`,
|
||||
requirement: issue.kind,
|
||||
fixHint,
|
||||
};
|
||||
}
|
||||
|
||||
function pairedRecordIssueToHealthFinding(issue: PairedRecordIssue): HealthFinding {
|
||||
return {
|
||||
checkId: DEVICE_PAIRING_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: issue.message,
|
||||
path: "devices.paired",
|
||||
target: issue.role ? `${issue.deviceId}:${issue.role}` : issue.deviceId,
|
||||
requirement: issue.kind,
|
||||
...(issue.fixHint ? { fixHint: issue.fixHint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function localDeviceAuthIssueToHealthFinding(issue: LocalDeviceAuthIssue): HealthFinding {
|
||||
return {
|
||||
checkId: DEVICE_PAIRING_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: issue.message,
|
||||
path: "identity.device-auth",
|
||||
target: `${issue.deviceId}:${issue.role}`,
|
||||
requirement: issue.kind,
|
||||
fixHint: issue.fixHint,
|
||||
};
|
||||
}
|
||||
|
||||
function pairingStoreReadIssueToHealthFinding(error: JsonFileReadError): HealthFinding {
|
||||
return {
|
||||
checkId: DEVICE_PAIRING_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: stripListMarker(formatPairingStoreReadIssue(error)),
|
||||
path: error.filePath,
|
||||
requirement: `pairing-store-${error.reason}`,
|
||||
fixHint: "Fix the JSON or file permissions, or move the store aside and re-pair devices.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectDevicePairingHealthFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
healthOk?: boolean;
|
||||
}): Promise<HealthFinding[]> {
|
||||
let snapshot: DoctorPairingSnapshot | null;
|
||||
try {
|
||||
snapshot = await loadDoctorPairingSnapshot({
|
||||
cfg: params.cfg,
|
||||
healthOk: params.healthOk ?? false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof JsonFileReadError) {
|
||||
return [pairingStoreReadIssueToHealthFinding(error)];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!snapshot) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...collectPendingPairingIssues(snapshot).map(pendingPairingIssueToHealthFinding),
|
||||
...collectPairedRecordIssues(snapshot).map(pairedRecordIssueToHealthFinding),
|
||||
...collectLocalDeviceAuthIssues(snapshot).map(localDeviceAuthIssueToHealthFinding),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits device pairing repair guidance from live gateway state or local pairing files.
|
||||
*
|
||||
@@ -508,9 +647,9 @@ export async function noteDevicePairingHealth(params: {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
...collectPendingPairingIssues(snapshot),
|
||||
...collectPairedRecordIssues(snapshot),
|
||||
...collectLocalDeviceAuthIssues(snapshot),
|
||||
...collectPendingPairingIssues(snapshot).map(formatPendingPairingIssue),
|
||||
...collectPairedRecordIssues(snapshot).map(formatPairedRecordIssue),
|
||||
...collectLocalDeviceAuthIssues(snapshot).map(formatLocalDeviceAuthIssue),
|
||||
];
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -76,6 +76,7 @@ const mocks = vi.hoisted(() => ({
|
||||
gatherDaemonStatus: vi.fn(),
|
||||
noteWorkspaceStatus: vi.fn(),
|
||||
collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]),
|
||||
collectDevicePairingHealthFindings: vi.fn(async () => []),
|
||||
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
|
||||
logConfigUpdated: vi.fn(),
|
||||
isRecord: vi.fn(
|
||||
@@ -272,6 +273,11 @@ vi.mock("../commands/doctor-workspace-status.js", () => ({
|
||||
collectWorkspaceStatusHealthFindings: mocks.collectWorkspaceStatusHealthFindings,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-device-pairing.js", () => ({
|
||||
collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings,
|
||||
noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
applyWizardMetadata: mocks.applyWizardMetadata,
|
||||
randomToken: vi.fn(() => "generated-gateway-token"),
|
||||
@@ -444,6 +450,8 @@ describe("doctor health contributions", () => {
|
||||
mocks.noteWorkspaceStatus.mockReset();
|
||||
mocks.collectWorkspaceStatusHealthFindings.mockReset();
|
||||
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
|
||||
mocks.collectDevicePairingHealthFindings.mockReset();
|
||||
mocks.collectDevicePairingHealthFindings.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1160,6 +1168,7 @@ describe("doctor health contributions", () => {
|
||||
expect(contributionIds).toContain("core/doctor/session-snapshots");
|
||||
expect(contributionIds).toContain("core/doctor/plugin-registry");
|
||||
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
|
||||
expect(contributionIds).toContain("core/doctor/device-pairing");
|
||||
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
|
||||
});
|
||||
|
||||
@@ -1256,6 +1265,39 @@ describe("doctor health contributions", () => {
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps device pairing opt-in for default lint selection", async () => {
|
||||
const contributionChecks = await resolveDoctorContributionHealthChecks();
|
||||
const devicePairingCheck = contributionChecks.find(
|
||||
(check) => check.id === "core/doctor/device-pairing",
|
||||
);
|
||||
expect(devicePairingCheck).toMatchObject({ defaultEnabled: false });
|
||||
expect(devicePairingCheck).toBeDefined();
|
||||
|
||||
const ctx = {
|
||||
cfg: { gateway: { mode: "local" } },
|
||||
mode: "lint",
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
} as const;
|
||||
const checks = [devicePairingCheck!];
|
||||
|
||||
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
|
||||
checksRun: 0,
|
||||
checksSkipped: 1,
|
||||
});
|
||||
expect(mocks.collectDevicePairingHealthFindings).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/device-pairing"] }),
|
||||
).resolves.toMatchObject({
|
||||
checksRun: 1,
|
||||
checksSkipped: 0,
|
||||
});
|
||||
expect(mocks.collectDevicePairingHealthFindings).toHaveBeenCalledWith({
|
||||
cfg: ctx.cfg,
|
||||
healthOk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses legacy run when a contribution also declares structured health", async () => {
|
||||
const legacyRun = vi.fn();
|
||||
const healthChecks = {
|
||||
|
||||
@@ -1755,6 +1755,16 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:device-pairing",
|
||||
label: "Device pairing",
|
||||
healthChecks: {
|
||||
id: "core/doctor/device-pairing",
|
||||
description: "Device pairing requests and stale device-auth records are findings.",
|
||||
defaultEnabled: false,
|
||||
async detect(ctx) {
|
||||
const { collectDevicePairingHealthFindings } =
|
||||
await import("../commands/doctor-device-pairing.js");
|
||||
return await collectDevicePairingHealthFindings({ cfg: ctx.cfg, healthOk: false });
|
||||
},
|
||||
},
|
||||
run: runDevicePairingHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
|
||||
Reference in New Issue
Block a user