Files
openclaw/src/cli/command-path-policy.test.ts
Gio Della-Libera 9a5f2f61e7 Doctor: add health-check contract and --lint validation (#80055)
* feat(doctor): add --lint mode + structured HealthFinding shape

Adds the core machinery for `openclaw doctor --lint` per the
doctor-lint-and-oc-rules upstream proposal. PR-1 of the proposal:
no new top-level verb, no public plugin SDK; everything internal.

Files:
- src/flows/checks.ts ? HealthFinding / HealthCheck / HealthCheckContext
   types. Findings carry severity per-finding; checks return
   readonly HealthFinding[]. Mode tag (doctor/lint/fix) lets a check
   distinguish the calling posture.
- src/flows/health-check-registry.ts ? module-level registry with
   duplicate-id rejection + test reset helper.
- src/flows/doctor-lint-flow.ts ? runner over registered checks.
   Catches throws into synthetic error findings (anchored at check id;
   message scrubbed of control chars, capped at 256 bytes). Sorts
   findings by severity desc, check id, path. Exports
   exitCodeFromFindings (1 if any warning/error, 0 otherwise).
- src/flows/doctor-core-checks.ts ? 4 modern HealthChecks rewriting
   logic from existing legacy run*Health functions:
     core/doctor/gateway-config            (warning)
     core/doctor/command-owner             (info)
     core/doctor/workspace-status          (info)
     core/doctor/final-config-validation   (error)
   Each was audited safe per the proposal's adapter constraints
   (no writes, no repair calls, no prompts, no probes incl. local-bind).
   Legacy run*Health contributions in doctor-health-contributions.ts
   are unchanged ? doctor mode (no --lint) still runs the existing 35.
- src/commands/doctor-lint.ts ? CLI dispatch for --lint. Reads config
   snapshot, builds HealthCheckContext (mode: "lint"), runs the registry,
   filters by --severity-min, emits human or JSON output, returns exit
   code from unfiltered set so --severity-min hides info findings
   without changing CI signal.
- src/cli/program/register.maintenance.ts ? adds --lint, --json,
   --severity-min, --skip, --only flags to existing doctor command.
   --lint branches to runDoctorLintCli; without --lint, doctor runs
   unchanged.

LoC: 382 src across 6 files. Tests + doc + oc-path-side rule packs
follow as separate commits on this branch.

* fix: avoid string spread in doctor errors

* chore: refresh plugin SDK API baseline

* docs: clarify doctor lint usage

* feat(doctor): prepare repairs for dry-run reporting
2026-05-17 12:29:57 -07:00

356 lines
12 KiB
TypeScript

import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CliCommandCatalogEntry, CliCommandPathPolicy } from "./command-catalog.js";
import {
resolveCliCatalogCommandPath,
resolveCliCommandPathPolicy,
resolveCliNetworkProxyPolicy,
} from "./command-path-policy.js";
const DEFAULT_EXPECTED_POLICY: CliCommandPathPolicy = {
bypassConfigGuard: false,
routeConfigGuard: "never",
loadPlugins: "never",
pluginRegistry: { scope: "all" },
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
};
type NetworkProxyResolver = Extract<
CliCommandPathPolicy["networkProxy"],
(ctx: { argv: string[]; commandPath: string[] }) => unknown
>;
type LoadPluginsResolver = Extract<
CliCommandPathPolicy["loadPlugins"],
(ctx: { argv: string[]; commandPath: string[]; jsonOutputMode: boolean }) => unknown
>;
function expectResolvedPolicy(
commandPath: string[],
expected: Partial<CliCommandPathPolicy>,
): void {
expect(resolveCliCommandPathPolicy(commandPath)).toEqual({
...DEFAULT_EXPECTED_POLICY,
...expected,
});
}
function expectNetworkProxyResolver(
policy: CliCommandPathPolicy,
): asserts policy is CliCommandPathPolicy & { networkProxy: NetworkProxyResolver } {
expect(typeof policy.networkProxy).toBe("function");
}
function expectLoadPluginsResolver(
policy: CliCommandPathPolicy,
): asserts policy is CliCommandPathPolicy & { loadPlugins: LoadPluginsResolver } {
expect(typeof policy.loadPlugins).toBe("function");
}
describe("command-path-policy", () => {
afterEach(() => {
vi.doUnmock("./command-catalog.js");
vi.resetModules();
});
it("resolves status policy with shared startup semantics", () => {
expectResolvedPolicy(["status"], {
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
ensureCliPath: false,
networkProxy: "bypass",
});
});
it("applies exact overrides after broader channel plugin rules", () => {
expectResolvedPolicy(["channels", "send"], {
loadPlugins: "always",
pluginRegistry: { scope: "configured-channels" },
});
expectResolvedPolicy(["channels", "login"], {
loadPlugins: "always",
pluginRegistry: { scope: "configured-channels" },
});
expectResolvedPolicy(["channels", "capabilities"], {
loadPlugins: "always",
pluginRegistry: { scope: "configured-channels" },
});
expectResolvedPolicy(["channels", "add"], {
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
});
const channelsStatusPolicy = resolveCliCommandPathPolicy(["channels", "status"]);
expect(channelsStatusPolicy).toEqual({
...DEFAULT_EXPECTED_POLICY,
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: channelsStatusPolicy.networkProxy,
});
expectNetworkProxyResolver(channelsStatusPolicy);
expect(
channelsStatusPolicy.networkProxy({
argv: ["node", "openclaw", "channels", "status"],
commandPath: ["channels", "status"],
}),
).toBe("bypass");
expect(
channelsStatusPolicy.networkProxy({
argv: ["node", "openclaw", "channels", "status", "--probe"],
commandPath: ["channels", "status"],
}),
).toBe("default");
expectResolvedPolicy(["channels", "list"], {
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
});
expectResolvedPolicy(["channels", "logs"], {
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
});
expectResolvedPolicy(["channels", "remove"], {
loadPlugins: "always",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
});
expectResolvedPolicy(["channels", "resolve"], {
loadPlugins: "always",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
});
});
it("keeps config-only agent commands on config-only startup", () => {
const agentPolicy = resolveCliCommandPathPolicy(["agent"]);
expect(agentPolicy).toEqual({
...DEFAULT_EXPECTED_POLICY,
loadPlugins: agentPolicy.loadPlugins,
pluginRegistry: { scope: "all" },
networkProxy: agentPolicy.networkProxy,
});
expectLoadPluginsResolver(agentPolicy);
expectNetworkProxyResolver(agentPolicy);
expect(
agentPolicy.loadPlugins({
argv: ["node", "openclaw", "agent"],
commandPath: ["agent"],
jsonOutputMode: false,
}),
).toBe(true);
expect(
agentPolicy.loadPlugins({
argv: ["node", "openclaw", "agent", "--json"],
commandPath: ["agent"],
jsonOutputMode: true,
}),
).toBe(false);
expect(
agentPolicy.loadPlugins({
argv: ["node", "openclaw", "agent", "--local"],
commandPath: ["agent"],
jsonOutputMode: true,
}),
).toBe(true);
expect(
agentPolicy.networkProxy({
argv: ["node", "openclaw", "agent"],
commandPath: ["agent"],
}),
).toBe("bypass");
expect(
agentPolicy.networkProxy({
argv: ["node", "openclaw", "agent", "--local"],
commandPath: ["agent"],
}),
).toBe("default");
for (const commandPath of [
["agents"],
["agents", "list"],
["agents", "bind"],
["agents", "bindings"],
["agents", "unbind"],
["agents", "set-identity"],
["agents", "delete"],
]) {
expectResolvedPolicy(commandPath, {
loadPlugins: "never",
networkProxy: "bypass",
});
}
});
it("resolves mixed startup-only rules", () => {
expectResolvedPolicy(["configure"], {
bypassConfigGuard: true,
loadPlugins: "never",
});
expectResolvedPolicy(["config"], {
bypassConfigGuard: true,
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["config", "set"], {
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["doctor"], {
bypassConfigGuard: true,
loadPlugins: "never",
});
expectResolvedPolicy(["config", "validate"], {
bypassConfigGuard: true,
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["gateway", "status"], {
routeConfigGuard: "always",
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["plugins", "update"], {
loadPlugins: "never",
hideBanner: true,
});
expectResolvedPolicy(["plugins", "list"], {
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
});
for (const commandPath of [
["plugins", "install"],
["plugins", "inspect"],
["plugins", "registry"],
["plugins", "doctor"],
]) {
expectResolvedPolicy(commandPath, {
loadPlugins: "never",
});
}
expectResolvedPolicy(["cron", "list"], {
bypassConfigGuard: true,
loadPlugins: "never",
networkProxy: "bypass",
});
});
it("defaults unknown command paths to network proxy routing", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "googlemeet", "login"])).toBe(
"default",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "tool", "image_generate"])).toBe(
"bypass",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "tools", "effective"])).toBe("bypass");
});
it("resolves static network proxy bypass policies from the catalog", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "status"])).toBe("bypass");
expect(
resolveCliNetworkProxyPolicy(["node", "openclaw", "config", "get", "proxy.enabled"]),
).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "proxy", "start"])).toBe("bypass");
});
it("resolves mixed network proxy policies from argv-sensitive catalog entries", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "run"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "health"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "run"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "status"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "--local"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "run"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status"])).toBe("bypass");
expect(
resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status", "--probe"]),
).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status", "--probe"])).toBe(
"default",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "info", "browser"])).toBe(
"bypass",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "check"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "list"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "search", "browser"])).toBe(
"default",
);
});
it("uses the longest catalog command path for deep network proxy overrides", async () => {
const catalog: readonly CliCommandCatalogEntry[] = [
{ commandPath: ["nodes"], policy: { networkProxy: "bypass" } },
{
commandPath: ["nodes", "camera", "snap"],
exact: true,
policy: { networkProxy: "default" },
},
];
vi.doMock("./command-catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./command-catalog.js")>();
return { ...actual, cliCommandCatalog: catalog };
});
const { resolveCliCatalogCommandPath, resolveCliNetworkProxyPolicy } = await importFreshModule<
typeof import("./command-path-policy.js")
>(import.meta.url, "./command-path-policy.js?catalog-overrides");
expect(resolveCliCatalogCommandPath(["node", "openclaw", "nodes", "camera", "snap"])).toEqual([
"nodes",
"camera",
"snap",
]);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "snap"])).toBe(
"default",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "list"])).toBe(
"bypass",
);
});
it("stops catalog command path resolution before positional arguments", () => {
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "config", "get", "proxy.enabled"]),
).toEqual(["config", "get"]);
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "message", "send", "--to", "demo"]),
).toEqual(["message"]);
});
it("treats bare gateway invocations with options as the gateway runtime", () => {
const argv = ["node", "openclaw", "gateway", "--port", "1234"];
expect(resolveCliCatalogCommandPath(argv)).toEqual(["gateway"]);
expect(resolveCliNetworkProxyPolicy(argv)).toBe("default");
});
it("does not let gateway run option values spoof bypass subcommands", () => {
for (const argv of [
["node", "openclaw", "gateway", "--token", "status"],
["node", "openclaw", "gateway", "--token=status"],
["node", "openclaw", "gateway", "--password", "health"],
["node", "openclaw", "gateway", "--password-file", "status"],
["node", "openclaw", "gateway", "--ws-log", "compact"],
]) {
expect(resolveCliCatalogCommandPath(argv), argv.join(" ")).toEqual(["gateway"]);
expect(resolveCliNetworkProxyPolicy(argv), argv.join(" ")).toBe("default");
}
});
it("still resolves real gateway bypass subcommands after their command token", () => {
expect(resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status"])).toEqual([
"gateway",
"status",
]);
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status", "--token", "secret"]),
).toEqual(["gateway", "status"]);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "status"])).toBe("bypass");
});
});