mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
refactor(cli): unify config-guard policy across dispatch paths (#117880)
This commit is contained in:
committed by
GitHub
parent
dc38411ca4
commit
a9bbf45d4b
@@ -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
@@ -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" } },
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 +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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user