refactor(cli): unify config-guard policy across dispatch paths (#117880)

This commit is contained in:
Peter Steinberger
2026-08-02 00:20:39 -07:00
committed by GitHub
parent dc38411ca4
commit a9bbf45d4b
14 changed files with 238 additions and 222 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
// The real route-first channel status path must stay out of local config/plugin runtimes.
// The real route-first channel status path must honor the shared catalog guard policy.
import { beforeEach, describe, expect, it, vi } from "vitest";
const loaded = vi.hoisted(() => {
@@ -55,7 +55,7 @@ describe("routed channels status cold imports", () => {
vi.clearAllMocks();
});
it("keeps successful JSON routes cold with and without probing", async () => {
it("keeps successful JSON routes out of config/plugin runtimes with and without probing", async () => {
for (const probe of [false, true]) {
vi.clearAllMocks();
const argv = ["node", "openclaw", "channels", "status", "--json"];
+58 -43
View File
@@ -6,10 +6,10 @@ export type CliCommandPluginLoadPolicy =
| "always"
| "text-only"
| ((ctx: { argv: string[]; commandPath: string[]; jsonOutputMode: boolean }) => boolean);
type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed";
type CliConfigGuardBypassPolicy =
| boolean
| ((ctx: { argv: string[]; commandPath: string[] }) => boolean);
type CliConfigGuardMode = "run" | "skip" | "when-suppressed";
type CliConfigGuardPolicy =
| CliConfigGuardMode
| ((ctx: { argv: string[]; commandPath: string[] }) => CliConfigGuardMode);
export type CliPluginRegistryScope = "all" | "channels" | "configured-channels";
export type CliPluginRegistryPolicy = {
scope: CliPluginRegistryScope;
@@ -36,8 +36,7 @@ type CliRoutedCommandId =
| "plugins-list";
export type CliCommandPathPolicy = {
bypassConfigGuard: CliConfigGuardBypassPolicy;
routeConfigGuard: CliRouteConfigGuardPolicy;
configGuard: CliConfigGuardPolicy;
loadPlugins: CliCommandPluginLoadPolicy;
pluginRegistry: CliPluginRegistryPolicy;
ownsProtocolStdout: boolean;
@@ -72,22 +71,22 @@ function hasCliOption(argv: readonly string[], name: string): boolean {
export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["setup"],
policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false },
policy: { configGuard: "skip", loadPlugins: "never", ensureCliPath: false },
},
{
commandPath: ["qa"],
// Private QA commands create or inspect repo-owned fixtures. They must not
// read, validate, migrate, or inherit proxy policy from operator state.
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["crestodian"], // hidden alias
policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false },
policy: { configGuard: "skip", loadPlugins: "never", ensureCliPath: false },
},
{
commandPath: ["agent"],
policy: {
bypassConfigGuard: ({ argv }) => !hasFlag(argv, "--local"),
configGuard: ({ argv }) => (hasFlag(argv, "--local") ? "run" : "skip"),
loadPlugins: ({ argv }) => hasFlag(argv, "--local"),
pluginRegistry: { scope: "all" },
networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"),
@@ -96,7 +95,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["agent", "exec"],
policy: {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
ownsProtocolStdout: true,
hideBanner: true,
@@ -104,7 +103,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
},
},
{ commandPath: ["message"], policy: { loadPlugins: "never" } },
{ commandPath: ["docs"], policy: { bypassConfigGuard: true } },
{ commandPath: ["docs"], policy: { configGuard: "skip" } },
{
commandPath: ["channels"],
policy: {
@@ -117,7 +116,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["agents"],
exact: true,
policy: { loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
route: { id: "agents-list" },
},
{
@@ -145,24 +144,25 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: { loadPlugins: "never" },
},
{ commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
{ commandPath: ["configure"], policy: { configGuard: "skip", loadPlugins: "never" } },
{
commandPath: ["config"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["config", "models"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["migrate"],
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["status"],
policy: {
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
ensureCliPath: false,
@@ -173,6 +173,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["health"],
policy: {
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
ensureCliPath: false,
@@ -191,7 +192,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["gateway", "status"],
exact: true,
policy: {
routeConfigGuard: "always",
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
},
@@ -206,7 +207,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
// The routed JSON command owns its config read; running the startup guard first
// duplicates config/state initialization before the health socket can open.
policy: { routeConfigGuard: "always", networkProxy: "bypass" },
policy: { configGuard: "skip", networkProxy: "bypass" },
route: { id: "gateway-health" },
},
{ commandPath: ["gateway", "install"], exact: true, policy: { networkProxy: "bypass" } },
@@ -220,7 +221,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["sessions"],
exact: true,
policy: { ensureCliPath: false, ownsProtocolStdout: true, networkProxy: "bypass" },
policy: {
configGuard: "skip",
ensureCliPath: false,
ownsProtocolStdout: true,
networkProxy: "bypass",
},
route: { id: "sessions" },
},
{
@@ -235,7 +241,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["agents", "list"],
// Text and JSON output are derived from config plus read-only channel
// metadata, so the route should not preload bundled plugin runtimes.
policy: { loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
route: { id: "agents-list" },
},
{
@@ -243,7 +249,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
// A path query must work before config validation and must not initialize state.
policy: {
bypassConfigGuard: true,
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
ownsProtocolStdout: true,
@@ -254,8 +260,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["config", "get"],
exact: true,
policy: {
bypassConfigGuard: true,
routeConfigGuard: "always",
configGuard: "skip",
ensureCliPath: false,
networkProxy: "bypass",
},
@@ -264,13 +269,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["config", "unset"],
exact: true,
policy: { ensureCliPath: false, networkProxy: "bypass" },
policy: { configGuard: "run", ensureCliPath: false, networkProxy: "bypass" },
route: { id: "config-unset" },
},
{
commandPath: ["models", "list"],
exact: true,
policy: { ensureCliPath: false, routeConfigGuard: "always", networkProxy: "bypass" },
policy: { configGuard: "skip", ensureCliPath: false, networkProxy: "bypass" },
route: { id: "models-list" },
},
{
@@ -278,7 +283,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: {
ensureCliPath: false,
routeConfigGuard: "always",
configGuard: "skip",
networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"),
},
route: { id: "models-status" },
@@ -287,6 +292,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["tasks", "list"],
exact: true,
policy: {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
@@ -297,6 +303,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["tasks", "audit"],
exact: true,
policy: {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
@@ -306,6 +313,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["tasks"],
policy: {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
@@ -328,11 +336,14 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
},
{ commandPath: ["approvals"], policy: { networkProxy: "bypass" } },
// automations is a commander alias for cron; argv-derived command paths keep the typed token.
{ commandPath: ["automations"], policy: { networkProxy: "bypass" } },
{ commandPath: ["backup"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } },
{
commandPath: ["automations"],
policy: { configGuard: "skip", networkProxy: "bypass" },
},
{ commandPath: ["backup"], policy: { configGuard: "skip", networkProxy: "bypass" } },
{ commandPath: ["chat"], policy: { networkProxy: "bypass" } },
{ commandPath: ["config"], policy: { networkProxy: "bypass" } },
{ commandPath: ["cron"], policy: { networkProxy: "bypass" } },
{ commandPath: ["cron"], policy: { configGuard: "skip", networkProxy: "bypass" } },
{ commandPath: ["dashboard"], policy: { networkProxy: "bypass" } },
{ commandPath: ["daemon"], policy: { networkProxy: "bypass" } },
{ commandPath: ["devices"], policy: { networkProxy: "bypass" } },
@@ -347,7 +358,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["doctor"],
policy: {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
// Shared-state maintenance must acquire exclusive ownership before any
// config-health observation can open the canonical SQLite database.
@@ -387,7 +398,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["worker"],
exact: true,
policy: {
bypassConfigGuard: true,
configGuard: "skip",
hideBanner: true,
loadPlugins: "never",
ownsProtocolStdout: true,
@@ -402,12 +413,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["completion"],
policy: {
bypassConfigGuard: true,
configGuard: "skip",
hideBanner: true,
networkProxy: "bypass",
},
},
{ commandPath: ["secrets"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } },
{ commandPath: ["secrets"], policy: { configGuard: "skip", networkProxy: "bypass" } },
{ commandPath: ["security"], policy: { networkProxy: "bypass" } },
{ commandPath: ["system"], policy: { networkProxy: "bypass" } },
{ commandPath: ["terminal"], policy: { networkProxy: "bypass" } },
@@ -417,12 +428,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["config", "validate"],
exact: true,
policy: { bypassConfigGuard: true, networkProxy: "bypass" },
policy: { configGuard: "skip", networkProxy: "bypass" },
},
{
commandPath: ["config", "schema"],
exact: true,
policy: { bypassConfigGuard: true, ownsProtocolStdout: true, networkProxy: "bypass" },
policy: { configGuard: "skip", ownsProtocolStdout: true, networkProxy: "bypass" },
},
{
commandPath: ["plugins", "update"],
@@ -432,7 +443,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["plugins", "list"],
exact: true,
policy: { ensureCliPath: false, loadPlugins: "never", networkProxy: "bypass" },
policy: {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
},
route: { id: "plugins-list" },
},
{
@@ -443,17 +459,17 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["onboard", "recommendations"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["onboard", "recommendations", "acknowledge"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["onboard", "recommendations", "refresh"],
exact: true,
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["channels", "add"],
@@ -485,8 +501,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["channels", "status"],
exact: true,
policy: {
bypassConfigGuard: true,
routeConfigGuard: "always",
configGuard: "skip",
loadPlugins: "never",
networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"),
},
@@ -495,7 +510,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["channels", "list"],
exact: true,
policy: { loadPlugins: "never", networkProxy: "bypass" },
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
route: { id: "channels-list" },
},
{ commandPath: ["skills"], exact: true, policy: { networkProxy: "bypass" } },
+1 -2
View File
@@ -44,7 +44,6 @@ describe("command-execution-startup", () => {
argv: ["node", "openclaw", "status", "--json"],
jsonOutputMode: true,
env: {},
routeMode: true,
}),
).toEqual({
invocation: {
@@ -58,7 +57,7 @@ describe("command-execution-startup", () => {
startupPolicy: {
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: false,
skipConfigGuard: true,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
},
-2
View File
@@ -19,7 +19,6 @@ export function resolveCliExecutionStartupContext(params: {
commandPath?: string[];
jsonOutputMode: boolean;
env?: NodeJS.ProcessEnv;
routeMode?: boolean;
}) {
const invocation = resolveCliArgvInvocation(params.argv);
// Commander owns the action path after parsing option values. Route-first
@@ -33,7 +32,6 @@ export function resolveCliExecutionStartupContext(params: {
commandPath,
jsonOutputMode: params.jsonOutputMode,
env: params.env,
routeMode: params.routeMode,
}),
};
}
+37 -32
View File
@@ -8,8 +8,7 @@ import {
} from "./command-path-policy.js";
const DEFAULT_EXPECTED_POLICY: CliCommandPathPolicy = {
bypassConfigGuard: false,
routeConfigGuard: "never",
configGuard: "run",
loadPlugins: "never",
pluginRegistry: { scope: "all" },
ownsProtocolStdout: false,
@@ -26,8 +25,8 @@ type LoadPluginsResolver = Extract<
CliCommandPathPolicy["loadPlugins"],
(ctx: { argv: string[]; commandPath: string[]; jsonOutputMode: boolean }) => unknown
>;
type ConfigGuardBypassResolver = Extract<
CliCommandPathPolicy["bypassConfigGuard"],
type ConfigGuardResolver = Extract<
CliCommandPathPolicy["configGuard"],
(ctx: { argv: string[]; commandPath: string[] }) => unknown
>;
@@ -53,10 +52,10 @@ function expectLoadPluginsResolver(
expect(typeof policy.loadPlugins).toBe("function");
}
function expectConfigGuardBypassResolver(
function expectConfigGuardResolver(
policy: CliCommandPathPolicy,
): asserts policy is CliCommandPathPolicy & { bypassConfigGuard: ConfigGuardBypassResolver } {
expect(typeof policy.bypassConfigGuard).toBe("function");
): asserts policy is CliCommandPathPolicy & { configGuard: ConfigGuardResolver } {
expect(typeof policy.configGuard).toBe("function");
}
describe("command-path-policy", () => {
@@ -67,7 +66,7 @@ describe("command-path-policy", () => {
it("resolves status policy with shared startup semantics", () => {
expectResolvedPolicy(["status"], {
routeConfigGuard: "never",
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "channels" },
ensureCliPath: false,
@@ -96,8 +95,7 @@ describe("command-path-policy", () => {
const channelsStatusPolicy = resolveCliCommandPathPolicy(["channels", "status"]);
expect(channelsStatusPolicy).toEqual({
...DEFAULT_EXPECTED_POLICY,
bypassConfigGuard: true,
routeConfigGuard: "always",
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: channelsStatusPolicy.networkProxy,
@@ -116,6 +114,7 @@ describe("command-path-policy", () => {
}),
).toBe("default");
expectResolvedPolicy(["channels", "list"], {
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "configured-channels" },
networkProxy: "bypass",
@@ -141,13 +140,13 @@ describe("command-path-policy", () => {
const agentPolicy = resolveCliCommandPathPolicy(["agent"]);
expect(agentPolicy).toEqual({
...DEFAULT_EXPECTED_POLICY,
bypassConfigGuard: agentPolicy.bypassConfigGuard,
configGuard: agentPolicy.configGuard,
loadPlugins: agentPolicy.loadPlugins,
pluginRegistry: { scope: "all" },
networkProxy: agentPolicy.networkProxy,
});
expectLoadPluginsResolver(agentPolicy);
expectConfigGuardBypassResolver(agentPolicy);
expectConfigGuardResolver(agentPolicy);
expectNetworkProxyResolver(agentPolicy);
expect(
agentPolicy.loadPlugins({
@@ -171,17 +170,17 @@ describe("command-path-policy", () => {
}),
).toBe(true);
expect(
agentPolicy.bypassConfigGuard({
agentPolicy.configGuard({
argv: ["node", "openclaw", "agent"],
commandPath: ["agent"],
}),
).toBe(true);
).toBe("skip");
expect(
agentPolicy.bypassConfigGuard({
agentPolicy.configGuard({
argv: ["node", "openclaw", "agent", "--local"],
commandPath: ["agent"],
}),
).toBe(false);
).toBe("run");
expect(
agentPolicy.networkProxy({
argv: ["node", "openclaw", "agent"],
@@ -196,7 +195,7 @@ describe("command-path-policy", () => {
).toBe("default");
expectResolvedPolicy(["agent", "exec"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
pluginRegistry: { scope: "all" },
ownsProtocolStdout: true,
@@ -204,9 +203,14 @@ describe("command-path-policy", () => {
networkProxy: "default",
});
for (const commandPath of [["agents"], ["agents", "list"]]) {
expectResolvedPolicy(commandPath, {
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
}
for (const commandPath of [
["agents"],
["agents", "list"],
["agents", "bind"],
["agents", "bindings"],
["agents", "unbind"],
@@ -222,28 +226,28 @@ describe("command-path-policy", () => {
it("resolves mixed startup-only rules", () => {
expectResolvedPolicy(["qa", "suite"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["worker"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
hideBanner: true,
ownsProtocolStdout: true,
networkProxy: "bypass",
});
expectResolvedPolicy(["configure"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
});
expectResolvedPolicy(["config"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["config", "file"], {
bypassConfigGuard: true,
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
ownsProtocolStdout: true,
@@ -256,7 +260,7 @@ describe("command-path-policy", () => {
const doctorPolicy = resolveCliCommandPathPolicy(["doctor"]);
expectNetworkProxyResolver(doctorPolicy);
expect(doctorPolicy).toMatchObject({
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
});
expect(
@@ -272,23 +276,23 @@ describe("command-path-policy", () => {
}),
).toBe("bypass");
expectResolvedPolicy(["config", "validate"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["config", "schema"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
ownsProtocolStdout: true,
networkProxy: "bypass",
});
expectResolvedPolicy(["gateway", "status"], {
routeConfigGuard: "always",
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
expectResolvedPolicy(["gateway", "health"], {
routeConfigGuard: "always",
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
@@ -297,12 +301,14 @@ describe("command-path-policy", () => {
hideBanner: true,
});
expectResolvedPolicy(["plugins", "list"], {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
});
for (const commandPath of [["tasks"], ["tasks", "list"], ["tasks", "audit"]]) {
expectResolvedPolicy(commandPath, {
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
@@ -319,7 +325,7 @@ describe("command-path-policy", () => {
});
}
expectResolvedPolicy(["cron", "list"], {
bypassConfigGuard: true,
configGuard: "skip",
loadPlugins: "never",
networkProxy: "bypass",
});
@@ -327,8 +333,7 @@ describe("command-path-policy", () => {
it("keeps routed and Commander config reads ahead of observing startup guards", () => {
expectResolvedPolicy(["config", "get"], {
bypassConfigGuard: true,
routeConfigGuard: "always",
configGuard: "skip",
ensureCliPath: false,
loadPlugins: "never",
networkProxy: "bypass",
+1 -6
View File
@@ -1,6 +1,5 @@
import { expectDefined } from "@openclaw/normalization-core";
// Resolves CLI command path policy from the declarative command catalog.
import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js";
import { resolveCliStartupCommandPath } from "./argv-invocation.js";
import { getCommandPathWithRootOptions } from "./argv.js";
import {
@@ -12,8 +11,7 @@ import { matchesCommandPath } from "./command-path-matches.js";
import { resolveGatewayCatalogCommandPath } from "./gateway-run-argv.js";
const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = {
bypassConfigGuard: false,
routeConfigGuard: "never",
configGuard: "run",
loadPlugins: "never",
pluginRegistry: { scope: "all" },
ownsProtocolStdout: false,
@@ -34,9 +32,6 @@ export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPa
}
Object.assign(resolvedPolicy, entry.policy);
}
if (isGatewayConfigBypassCommandPath(commandPath)) {
resolvedPolicy.bypassConfigGuard = true;
}
return resolvedPolicy;
}
+75 -52
View File
@@ -1,16 +1,15 @@
// Command startup policy tests cover which CLI commands require startup side effects.
import { describe, expect, it } from "vitest";
import {
resolveCliStartupPolicy,
shouldBypassConfigGuardForCommandPath,
} from "./command-startup-policy.js";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cliCommandCatalog } from "./command-catalog.js";
import { resolveCliExecutionStartupContext } from "./command-execution-startup.js";
import { resolveCliStartupPolicy } from "./command-startup-policy.js";
function resolvePolicy(params: {
argv?: string[];
commandPath: string[];
jsonOutputMode?: boolean;
env?: NodeJS.ProcessEnv;
routeMode?: boolean;
}) {
return resolveCliStartupPolicy({
jsonOutputMode: false,
@@ -19,44 +18,83 @@ function resolvePolicy(params: {
}
describe("command-startup-policy", () => {
it("matches config guard bypass commands", () => {
expect(shouldBypassConfigGuardForCommandPath(["backup", "create"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "file"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["docs"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["agent", "exec"])).toBe(true);
expect(shouldBypassConfigGuardForCommandPath(["agent"], ["node", "openclaw", "agent"])).toBe(
true,
);
expect(
shouldBypassConfigGuardForCommandPath(["agent"], ["node", "openclaw", "agent", "--local"]),
).toBe(false);
expect(shouldBypassConfigGuardForCommandPath(["config", "set"])).toBe(false);
expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false);
afterEach(() => {
vi.doUnmock("./command-path-policy.js");
vi.resetModules();
});
it("matches route-first config guard skip policy", () => {
it("resolves config guard policy for Commander and invocation-aware commands", () => {
for (const commandPath of [
["backup", "create"],
["config"],
["config", "file"],
["config", "validate"],
["config", "schema"],
["docs"],
["agent", "exec"],
["status"],
]) {
expect(resolvePolicy({ commandPath }).skipConfigGuard, commandPath.join(" ")).toBe(true);
}
expect(
resolvePolicy({
commandPath: ["status"],
jsonOutputMode: true,
routeMode: true,
}).skipConfigGuard,
).toBe(false);
expect(
resolvePolicy({
commandPath: ["gateway", "status"],
routeMode: true,
argv: ["node", "openclaw", "agent"],
commandPath: ["agent"],
}).skipConfigGuard,
).toBe(true);
expect(
resolvePolicy({
commandPath: ["status"],
routeMode: true,
argv: ["node", "openclaw", "agent", "--local"],
commandPath: ["agent"],
}).skipConfigGuard,
).toBe(false);
expect(resolvePolicy({ commandPath: ["config", "set"] }).skipConfigGuard).toBe(false);
});
it("keeps every route-first command on the same config guard declaration as Commander", () => {
for (const entry of cliCommandCatalog.filter((candidate) => candidate.route)) {
expect(entry.policy?.configGuard, entry.commandPath.join(" ")).toBeDefined();
for (const jsonOutputMode of [false, true]) {
const argv = ["node", "openclaw", ...entry.commandPath];
const expectedSkip = entry.commandPath.join(" ") !== "config unset";
const routed = resolveCliExecutionStartupContext({ argv, jsonOutputMode });
const commander = resolveCliExecutionStartupContext({
argv,
commandPath: [...entry.commandPath],
jsonOutputMode,
});
expect(routed.startupPolicy.skipConfigGuard, entry.commandPath.join(" ")).toBe(
commander.startupPolicy.skipConfigGuard,
);
expect(routed.startupPolicy.skipConfigGuard, entry.commandPath.join(" ")).toBe(
expectedSkip,
);
}
}
});
it("skips when-suppressed guards only for suppressed output", async () => {
vi.doMock("./command-path-policy.js", () => ({
resolveCliCommandPathPolicy: () => ({
configGuard: "when-suppressed",
loadPlugins: "never",
pluginRegistry: { scope: "all" },
ownsProtocolStdout: false,
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
}),
}));
const { resolveCliStartupPolicy: resolveWithSuppressedGuard } = await importFreshModule<
typeof import("./command-startup-policy.js")
>(import.meta.url, "./command-startup-policy.js?when-suppressed");
expect(
resolveWithSuppressedGuard({ commandPath: ["test"], jsonOutputMode: false }).skipConfigGuard,
).toBe(false);
expect(
resolveWithSuppressedGuard({ commandPath: ["test"], jsonOutputMode: true }).skipConfigGuard,
).toBe(true);
});
it("matches plugin preload policy", () => {
@@ -220,7 +258,7 @@ describe("command-startup-policy", () => {
}
});
it("aggregates startup policy for commander and route-first callers", () => {
it("aggregates startup policy for both dispatch paths", () => {
expect(
resolveCliStartupPolicy({
commandPath: ["status"],
@@ -230,22 +268,7 @@ describe("command-startup-policy", () => {
).toEqual({
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: false,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
});
expect(
resolveCliStartupPolicy({
commandPath: ["status"],
jsonOutputMode: true,
env: {},
routeMode: true,
}),
).toEqual({
suppressDoctorStdout: true,
hideBanner: false,
skipConfigGuard: false,
skipConfigGuard: true,
loadPlugins: false,
pluginRegistry: { scope: "channels" },
});
@@ -266,7 +289,7 @@ describe("command-startup-policy", () => {
it("isolates cloud worker startup", () => {
const policy = resolvePolicy({ commandPath: ["worker"] });
expect(shouldBypassConfigGuardForCommandPath(["worker"])).toBe(true);
expect(policy.skipConfigGuard).toBe(true);
expect(policy.hideBanner).toBe(true);
expect(policy.loadPlugins).toBe(false);
expect(policy.suppressDoctorStdout).toBe(true);
+6 -15
View File
@@ -3,16 +3,6 @@ import { isTruthyEnvValue } from "../infra/env.js";
import type { CliCommandPluginLoadPolicy } from "./command-catalog.js";
import { resolveCliCommandPathPolicy } from "./command-path-policy.js";
export function shouldBypassConfigGuardForCommandPath(
commandPath: string[],
argv: string[] = [],
): boolean {
const bypassConfigGuard = resolveCliCommandPathPolicy(commandPath).bypassConfigGuard;
return typeof bypassConfigGuard === "function"
? bypassConfigGuard({ argv, commandPath })
: bypassConfigGuard;
}
function shouldLoadPlugins(params: {
argv?: string[];
commandPath: string[];
@@ -36,19 +26,20 @@ export function resolveCliStartupPolicy(params: {
commandPath: string[];
jsonOutputMode: boolean;
env?: NodeJS.ProcessEnv;
routeMode?: boolean;
}) {
const commandPolicy = resolveCliCommandPathPolicy(params.commandPath);
// Protocol commands own stdout from process startup, before their action installs later routing.
const suppressDoctorStdout = params.jsonOutputMode || commandPolicy.ownsProtocolStdout;
const configGuard =
typeof commandPolicy.configGuard === "function"
? commandPolicy.configGuard({ argv: params.argv ?? [], commandPath: params.commandPath })
: commandPolicy.configGuard;
const env = params.env ?? process.env;
return {
suppressDoctorStdout,
hideBanner: isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) || commandPolicy.hideBanner,
skipConfigGuard: params.routeMode
? commandPolicy.routeConfigGuard === "always" ||
(commandPolicy.routeConfigGuard === "when-suppressed" && suppressDoctorStdout)
: false,
skipConfigGuard:
configGuard === "skip" || (configGuard === "when-suppressed" && suppressDoctorStdout),
loadPlugins: shouldLoadPlugins({
argv: params.argv,
commandPath: params.commandPath,
+35 -37
View File
@@ -270,6 +270,10 @@ describe("registerPreActionHooks", () => {
.option("--json")
.action(() => {});
config.command("file").action(() => {});
config
.command("unset")
.argument("<path>")
.action(() => {});
config
.command("validate")
.option("--json")
@@ -300,7 +304,7 @@ describe("registerPreActionHooks", () => {
await preActionHook(program, actionCommand);
}
it("handles debug mode and config-only command preaction", async () => {
it("applies shared skip policy to routed reads on the Commander path", async () => {
const processTitleSetSpy = vi.spyOn(process, "title", "set");
await runPreAction({
parseArgv: ["status"],
@@ -309,11 +313,7 @@ describe("registerPreActionHooks", () => {
expect(emitCliBannerMock).toHaveBeenCalledWith("9.9.9-test");
expect(setVerboseMock).toHaveBeenCalledWith(true);
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
measure: expect.any(Function),
commandPath: ["status"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
expect(processTitleSetSpy).toHaveBeenCalledWith("openclaw-status");
@@ -325,11 +325,7 @@ describe("registerPreActionHooks", () => {
expect(setVerboseMock).toHaveBeenCalledWith(false);
expect(process.env.NODE_NO_WARNINGS).toBe("1");
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
measure: expect.any(Function),
commandPath: ["agents", "list"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
processTitleSetSpy.mockRestore();
});
@@ -690,7 +686,7 @@ describe("registerPreActionHooks", () => {
});
expect(emitCliBannerMock).not.toHaveBeenCalled();
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
});
it("bootstraps when Commander consumed --help as a required option value", async () => {
@@ -754,12 +750,7 @@ describe("registerPreActionHooks", () => {
processArgv: ["node", "openclaw", "status", "--json"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
measure: expect.any(Function),
commandPath: ["status"],
suppressDoctorStdout: true,
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
vi.clearAllMocks();
@@ -921,22 +912,9 @@ describe("registerPreActionHooks", () => {
});
});
it.each([
{
name: "keeps a remote call migration-free",
argv: ["node", "openclaw", "gateway", "--token", "secret", "call", "health", "--json"],
expectedPath: ["gateway", "call"],
expectedMigration: false,
},
{
name: "keeps health on the invalid-config allowlist",
argv: ["node", "openclaw", "gateway", "--port", "19083", "health", "--json"],
expectedPath: ["gateway", "health"],
expectedMigration: true,
},
])("uses the Commander path past parent option values and $name", async (testCase) => {
it("uses the Commander path past parent option values for gateway calls", async () => {
const parseProgram = buildProgram();
process.argv = testCase.argv;
process.argv = ["node", "openclaw", "gateway", "--token", "secret", "call", "health", "--json"];
await parseProgram.parseAsync(process.argv);
@@ -944,12 +922,19 @@ describe("registerPreActionHooks", () => {
expect(bootstrap).toEqual({
runtime: runtimeMock,
measure: expect.any(Function),
commandPath: testCase.expectedPath,
commandPath: ["gateway", "call"],
suppressDoctorStdout: true,
});
expect(shouldMigrateStateFromPath(bootstrap?.commandPath ?? [])).toBe(
testCase.expectedMigration,
);
expect(shouldMigrateStateFromPath(bootstrap?.commandPath ?? [])).toBe(false);
});
it("uses the shared skip policy for gateway health on the Commander path", async () => {
const parseProgram = buildProgram();
process.argv = ["node", "openclaw", "gateway", "--port", "19083", "health", "--json"];
await parseProgram.parseAsync(process.argv);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
});
it("does not preload plugins for agents list JSON output", async () => {
@@ -1026,6 +1011,19 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("keeps config guard for config unset mutations", async () => {
await runPreAction({
parseArgv: ["config", "unset", "gateway.port"],
processArgv: ["node", "openclaw", "config", "unset", "gateway.port"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
measure: expect.any(Function),
commandPath: ["config", "unset"],
});
});
it("bypasses config guard for backup create", async () => {
await runPreAction({
parseArgv: ["backup", "create"],
+1 -4
View File
@@ -12,7 +12,6 @@ import {
ensureCliExecutionBootstrap,
resolveCliExecutionStartupContext,
} from "../command-execution-startup.js";
import { shouldBypassConfigGuardForCommandPath } from "../command-startup-policy.js";
import { applyResolvedCommandOutputMode } from "../json-output-mode.js";
import {
resolvePluginInstallInvalidConfigPolicy,
@@ -125,7 +124,6 @@ export function registerPreActionHooks(program: Command, programVersion: string)
jsonOutputMode,
env: process.env,
});
const bypassConfigGuard = shouldBypassConfigGuardForCommandPath(commandPath, argv);
await applyCliExecutionStartupPresentation({
startupPolicy,
version: programVersion,
@@ -140,7 +138,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
process.env.NODE_NO_WARNINGS ??= "1";
}
if (
bypassConfigGuard ||
startupPolicy.skipConfigGuard ||
isGuidedConfigAction(actionCommand) ||
isGuidedConfigCommandPath(commandPath)
) {
@@ -185,7 +183,6 @@ export function registerPreActionHooks(program: Command, programVersion: string)
...(beforeStateMigrations ? { beforeStateMigrations } : {}),
...(skipPristineStartupStateMigrations ? { skipPristineStartupStateMigrations: true } : {}),
...(skipPristineCoreStateMigrations ? { skipPristineCoreStateMigrations: true } : {}),
skipConfigGuard: bypassConfigGuard,
});
if (beforeStateMigrations) {
const { reloadTrustedGatewayRunEnvironment } =
+22 -19
View File
@@ -92,29 +92,24 @@ describe("tryRouteCli", () => {
}
});
it("keeps config guard for routed status --json commands", async () => {
it("skips config guard for routed status --json commands", async () => {
await expect(tryRouteCli(["node", "openclaw", "status", "--json"])).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(firstConfigReadyCall()?.commandPath).toEqual(["status"]);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("keeps config guard for the parent tasks JSON list alias", async () => {
it("skips config guard for the parent tasks JSON list alias", async () => {
await expect(tryRouteCli(["node", "openclaw", "tasks", "--json"])).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(firstConfigReadyCall()?.commandPath).toEqual(["tasks"]);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("does not pass suppressDoctorStdout for routed non-json commands", async () => {
it("skips config guard but still loads requested plugins for routed text commands", async () => {
await expect(tryRouteCli(["node", "openclaw", "status"])).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
const configReadyCall = firstConfigReadyCall();
expect(typeof configReadyCall?.runtime).toBe("object");
expect(configReadyCall?.commandPath).toEqual(["status"]);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
scope: "channels",
});
@@ -146,6 +141,15 @@ describe("tryRouteCli", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("keeps config guard for routed config mutations", async () => {
await expect(
tryRouteCli(["node", "openclaw", "config", "unset", "gateway.port"]),
).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(firstConfigReadyCall()?.commandPath).toEqual(["config", "unset"]);
});
it("keeps logs routed to stderr for routed --json commands", async () => {
findRoutedCommandMock.mockReturnValue({
loadPlugins: true,
@@ -201,8 +205,9 @@ describe("tryRouteCli", () => {
it("routes status when root options precede the command", async () => {
const capturedLogLevels: Array<string | undefined> = [];
ensureConfigReadyMock.mockImplementationOnce(async () => {
runRouteMock.mockImplementationOnce(async () => {
capturedLogLevels.push(process.env.OPENCLAW_LOG_LEVEL);
return true;
});
await expect(tryRouteCli(["node", "openclaw", "--log-level", "debug", "status"])).resolves.toBe(
@@ -213,10 +218,7 @@ describe("tryRouteCli", () => {
["status"],
["node", "openclaw", "--log-level", "debug", "status"],
);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
const configReadyCall = firstConfigReadyCall();
expect(typeof configReadyCall?.runtime).toBe("object");
expect(configReadyCall?.commandPath).toEqual(["status"]);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
scope: "channels",
});
@@ -226,15 +228,16 @@ describe("tryRouteCli", () => {
it("applies routed log level options after the command", async () => {
const capturedLogLevels: Array<string | undefined> = [];
ensureConfigReadyMock.mockImplementationOnce(async () => {
runRouteMock.mockImplementationOnce(async () => {
capturedLogLevels.push(process.env.OPENCLAW_LOG_LEVEL);
return true;
});
await expect(tryRouteCli(["node", "openclaw", "status", "--log-level=trace"])).resolves.toBe(
true,
);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(runRouteMock).toHaveBeenCalledTimes(1);
expect(capturedLogLevels).toEqual(["trace"]);
expect(process.env.OPENCLAW_LOG_LEVEL).toBe("trace");
@@ -245,7 +248,7 @@ describe("tryRouteCli", () => {
tryRouteCli(["node", "openclaw", "--log-level", "debug", "status", "--log-level=trace"]),
).resolves.toBe(true);
expect(ensureConfigReadyMock).toHaveBeenCalledTimes(1);
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(runRouteMock).toHaveBeenCalledTimes(1);
expect(process.env.OPENCLAW_LOG_LEVEL).toBe("trace");
});
-1
View File
@@ -59,7 +59,6 @@ async function prepareRoutedCommand(params: {
argv: params.argv,
jsonOutputMode: params.machineOutput === true || hasFlag(params.argv, "--json"),
env: process.env,
routeMode: true,
});
const { VERSION } = await import("../version.js");
await applyCliExecutionStartupPresentation({
-1
View File
@@ -186,7 +186,6 @@ async function tryRunGatewayRunFastPath(
argv,
commandPath,
jsonOutputMode: hasJsonOutputFlag(argv),
routeMode: true,
});
if (!startupPolicy.hideBanner) {
emitCliBanner(VERSION, { argv });
@@ -22,9 +22,3 @@ export function canSkipGatewayConfigLoad(params: {
hasExplicitGatewayConnectionAuth(params.explicitAuth)
);
}
/** Returns true for command families that intentionally bypass gateway config loading. */
export function isGatewayConfigBypassCommandPath(commandPath: readonly string[]): boolean {
// Command paths come from raw argv, so the automations alias keeps its typed token.
return commandPath[0] === "cron" || commandPath[0] === "automations";
}