CLI: resolve parent/subcommand option collisions (#18725)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b7e51cf909
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-17 20:57:09 -05:00
committed by GitHub
parent fa4f66255c
commit 985ec71c55
17 changed files with 856 additions and 38 deletions

View File

@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai
- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT.
- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
- CLI: fix parent/subcommand option collisions across gateway, daemon, update, ACP, and browser command flows, while preserving legacy `browser set headers --json <payload>` compatibility.
- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502)
- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544)
- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation.

View File

@@ -430,7 +430,7 @@ State:
- `openclaw browser storage local set theme dark`
- `openclaw browser storage session clear`
- `openclaw browser set offline on`
- `openclaw browser set headers --json '{"X-Debug":"1"}'`
- `openclaw browser set headers --headers-json '{"X-Debug":"1"}'`
- `openclaw browser set credentials user pass`
- `openclaw browser set credentials --clear`
- `openclaw browser set geo 37.7749 -122.4194 --origin "https://example.com"`
@@ -542,7 +542,7 @@ These are useful for “make the site behave like X” workflows:
- Cookies: `cookies`, `cookies set`, `cookies clear`
- Storage: `storage local|session get|set|clear`
- Offline: `set offline on|off`
- Headers: `set headers --json '{"X-Debug":"1"}'` (or `--clear`)
- Headers: `set headers --headers-json '{"X-Debug":"1"}'` (legacy `set headers --json '{"X-Debug":"1"}'` remains supported)
- HTTP basic auth: `set credentials user pass` (or `--clear`)
- Geolocation: `set geo <lat> <lon> --origin "https://example.com"` (or `--clear`)
- Media: `set media dark|light|no-preference|none`

View File

@@ -0,0 +1,45 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runAcpClientInteractive = vi.fn(async () => {});
const serveAcpGateway = vi.fn(async () => {});
const defaultRuntime = {
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../acp/client.js", () => ({
runAcpClientInteractive: (opts: unknown) => runAcpClientInteractive(opts),
}));
vi.mock("../acp/server.js", () => ({
serveAcpGateway: (opts: unknown) => serveAcpGateway(opts),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("acp cli option collisions", () => {
beforeEach(() => {
runAcpClientInteractive.mockClear();
serveAcpGateway.mockClear();
defaultRuntime.error.mockClear();
defaultRuntime.exit.mockClear();
});
it("forwards --verbose to `acp client` when parent and child option names collide", async () => {
const { registerAcpCli } = await import("./acp-cli.js");
const program = new Command();
registerAcpCli(program);
await program.parseAsync(["acp", "client", "--verbose"], { from: "user" });
expect(runAcpClientInteractive).toHaveBeenCalledWith(
expect.objectContaining({
verbose: true,
}),
);
});
});

View File

@@ -4,6 +4,7 @@ import { serveAcpGateway } from "../acp/server.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { inheritOptionFromParent } from "./command-options.js";
export function registerAcpCli(program: Command) {
const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway");
@@ -49,14 +50,15 @@ export function registerAcpCli(program: Command) {
.option("--server-args <args...>", "Extra arguments for the ACP server")
.option("--server-verbose", "Enable verbose logging on the ACP server", false)
.option("--verbose, -v", "Verbose client logging", false)
.action(async (opts) => {
.action(async (opts, command) => {
const inheritedVerbose = inheritOptionFromParent<boolean>(command, "verbose");
try {
await runAcpClientInteractive({
cwd: opts.cwd as string | undefined,
serverCommand: opts.server as string | undefined,
serverArgs: opts.serverArgs as string[] | undefined,
serverVerbose: Boolean(opts.serverVerbose),
verbose: Boolean(opts.verbose),
verbose: Boolean(opts.verbose || inheritedVerbose),
});
} catch (err) {
defaultRuntime.error(String(err));

View File

@@ -2,6 +2,20 @@ import type { Command } from "commander";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
import { inheritOptionFromParent } from "./command-options.js";
function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined {
const local = typeof rawTargetId === "string" ? rawTargetId.trim() : "";
if (local) {
return local;
}
const inherited = inheritOptionFromParent<string>(command, "targetId");
if (typeof inherited !== "string") {
return undefined;
}
const trimmed = inherited.trim();
return trimmed ? trimmed : undefined;
}
export function registerBrowserCookiesAndStorageCommands(
browser: Command,
@@ -14,6 +28,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
parent,
@@ -21,7 +36,7 @@ export function registerBrowserCookiesAndStorageCommands(
method: "GET",
path: "/cookies",
query: {
targetId: opts.targetId?.trim() || undefined,
targetId,
profile,
},
},
@@ -48,6 +63,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (name: string, value: string, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest(
parent,
@@ -56,7 +72,7 @@ export function registerBrowserCookiesAndStorageCommands(
path: "/cookies/set",
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
targetId,
cookie: { name, value, url: opts.url },
},
},
@@ -80,6 +96,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd);
try {
const result = await callBrowserRequest(
parent,
@@ -88,7 +105,7 @@ export function registerBrowserCookiesAndStorageCommands(
path: "/cookies/clear",
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
targetId,
},
},
{ timeoutMs: 20000 },
@@ -117,6 +134,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (key: string | undefined, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
try {
const result = await callBrowserRequest<{ values?: Record<string, string> }>(
parent,
@@ -125,7 +143,7 @@ export function registerBrowserCookiesAndStorageCommands(
path: `/storage/${kind}`,
query: {
key: key?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
targetId,
profile,
},
},
@@ -151,6 +169,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (key: string, value: string, opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
try {
const result = await callBrowserRequest(
parent,
@@ -161,7 +180,7 @@ export function registerBrowserCookiesAndStorageCommands(
body: {
key,
value,
targetId: opts.targetId?.trim() || undefined,
targetId,
},
},
{ timeoutMs: 20000 },
@@ -184,6 +203,7 @@ export function registerBrowserCookiesAndStorageCommands(
.action(async (opts, cmd2) => {
const parent = parentOpts(cmd2);
const profile = parent?.browserProfile;
const targetId = resolveTargetId(opts.targetId, cmd2);
try {
const result = await callBrowserRequest(
parent,
@@ -192,7 +212,7 @@ export function registerBrowserCookiesAndStorageCommands(
path: `/storage/${kind}/clear`,
query: profile ? { profile } : undefined,
body: {
targetId: opts.targetId?.trim() || undefined,
targetId,
},
},
{ timeoutMs: 20000 },

View File

@@ -0,0 +1,87 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserStateCommands } from "./browser-cli-state.js";
const mocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn(async () => ({ ok: true })),
runBrowserResizeWithOutput: vi.fn(async () => {}),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
}));
vi.mock("./browser-cli-shared.js", () => ({
callBrowserRequest: (...args: unknown[]) => mocks.callBrowserRequest(...args),
}));
vi.mock("./browser-cli-resize.js", () => ({
runBrowserResizeWithOutput: (params: unknown) => mocks.runBrowserResizeWithOutput(params),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
describe("browser state option collisions", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
mocks.runBrowserResizeWithOutput.mockClear();
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.exit.mockClear();
});
it("forwards parent-captured --target-id on `browser cookies set`", async () => {
const program = new Command();
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile")
.option("--json", "Output JSON", false);
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserStateCommands(browser, parentOpts);
await program.parseAsync(
[
"browser",
"cookies",
"set",
"session",
"abc",
"--url",
"https://example.com",
"--target-id",
"tab-1",
],
{ from: "user" },
);
const call = mocks.callBrowserRequest.mock.calls.at(-1);
expect(call).toBeDefined();
const request = call?.[1] as { body?: { targetId?: string } };
expect(request.body?.targetId).toBe("tab-1");
});
it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => {
const program = new Command();
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile")
.option("--json", "Output JSON", false);
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
registerBrowserStateCommands(browser, parentOpts);
await program.parseAsync(["browser", "set", "headers", "--json", '{"x-auth":"ok"}'], {
from: "user",
});
const call = mocks.callBrowserRequest.mock.calls.at(-1);
expect(call).toBeDefined();
const request = call?.[1] as { body?: { headers?: Record<string, string> } };
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
});
});

View File

@@ -102,14 +102,21 @@ export function registerBrowserStateCommands(
set
.command("headers")
.description("Set extra HTTP headers (JSON object)")
.requiredOption("--json <json>", "JSON object of headers")
.argument("[headersJson]", "JSON object of headers (alternative to --headers-json)")
.option("--headers-json <json>", "JSON object of headers")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.action(async (opts, cmd) => {
.action(async (headersJson: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
const parsed = JSON.parse(String(opts.json)) as unknown;
const headersJsonValue =
(typeof opts.headersJson === "string" && opts.headersJson.trim()) ||
(headersJson?.trim() ? headersJson.trim() : undefined);
if (!headersJsonValue) {
throw new Error("Missing headers JSON (pass --headers-json or positional JSON argument)");
}
const parsed = JSON.parse(String(headersJsonValue)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("headers json must be an object");
throw new Error("Headers JSON must be a JSON object");
}
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {

View File

@@ -0,0 +1,71 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { inheritOptionFromParent } from "./command-options.js";
describe("inheritOptionFromParent", () => {
it("inherits from grandparent when parent does not define the option", async () => {
const program = new Command().option("--token <token>", "Root token");
const gateway = program.command("gateway");
let inherited: string | undefined;
gateway
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
await program.parseAsync(["--token", "root-token", "gateway", "run"], { from: "user" });
expect(inherited).toBe("root-token");
});
it("prefers nearest ancestor value when multiple ancestors set the same option", async () => {
const program = new Command().option("--token <token>", "Root token");
const gateway = program.command("gateway").option("--token <token>", "Gateway token");
let inherited: string | undefined;
gateway
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
await program.parseAsync(
["--token", "root-token", "gateway", "--token", "gateway-token", "run"],
{ from: "user" },
);
expect(inherited).toBe("gateway-token");
});
it("does not inherit when the child option was set explicitly", async () => {
const program = new Command().option("--token <token>", "Root token");
const gateway = program.command("gateway").option("--token <token>", "Gateway token");
const run = gateway.command("run").option("--token <token>", "Run token");
program.setOptionValueWithSource("token", "root-token", "cli");
gateway.setOptionValueWithSource("token", "gateway-token", "cli");
run.setOptionValueWithSource("token", "run-token", "cli");
expect(inheritOptionFromParent<string>(run, "token")).toBeUndefined();
});
it("does not inherit from ancestors beyond the bounded traversal depth", async () => {
const program = new Command().option("--token <token>", "Root token");
const level1 = program.command("level1");
const level2 = level1.command("level2");
let inherited: string | undefined;
level2
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
await program.parseAsync(["--token", "root-token", "level1", "level2", "run"], {
from: "user",
});
expect(inherited).toBeUndefined();
});
});

View File

@@ -6,3 +6,39 @@ export function hasExplicitOptions(command: Command, names: readonly string[]):
}
return names.some((name) => command.getOptionValueSource(name) === "cli");
}
function getOptionSource(command: Command, name: string): string | undefined {
if (typeof command.getOptionValueSource !== "function") {
return undefined;
}
return command.getOptionValueSource(name);
}
// Defensive guardrail: allow expected parent/grandparent inheritance without unbounded deep traversal.
const MAX_INHERIT_DEPTH = 2;
export function inheritOptionFromParent<T = unknown>(
command: Command | undefined,
name: string,
): T | undefined {
if (!command) {
return undefined;
}
const childSource = getOptionSource(command, name);
if (childSource && childSource !== "default") {
return undefined;
}
let depth = 0;
let ancestor = command.parent;
while (ancestor && depth < MAX_INHERIT_DEPTH) {
const source = getOptionSource(ancestor, name);
if (source && source !== "default") {
return ancestor.opts<Record<string, unknown>>()[name] as T | undefined;
}
depth += 1;
ancestor = ancestor.parent;
}
return undefined;
}

View File

@@ -0,0 +1,72 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { addGatewayServiceCommands } from "./register-service-commands.js";
const runDaemonInstall = vi.fn(async () => {});
const runDaemonRestart = vi.fn(async () => {});
const runDaemonStart = vi.fn(async () => {});
const runDaemonStatus = vi.fn(async () => {});
const runDaemonStop = vi.fn(async () => {});
const runDaemonUninstall = vi.fn(async () => {});
vi.mock("./runners.js", () => ({
runDaemonInstall: (opts: unknown) => runDaemonInstall(opts),
runDaemonRestart: (opts: unknown) => runDaemonRestart(opts),
runDaemonStart: (opts: unknown) => runDaemonStart(opts),
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
runDaemonStop: (opts: unknown) => runDaemonStop(opts),
runDaemonUninstall: (opts: unknown) => runDaemonUninstall(opts),
}));
function createGatewayParentLikeCommand() {
const gateway = new Command().name("gateway");
// Mirror overlapping root gateway options that conflict with service subcommand options.
gateway.option("--port <port>", "Port for the gateway WebSocket");
gateway.option("--token <token>", "Gateway token");
gateway.option("--password <password>", "Gateway password");
gateway.option("--force", "Gateway run --force", false);
addGatewayServiceCommands(gateway);
return gateway;
}
describe("addGatewayServiceCommands", () => {
beforeEach(() => {
runDaemonInstall.mockClear();
runDaemonRestart.mockClear();
runDaemonStart.mockClear();
runDaemonStatus.mockClear();
runDaemonStop.mockClear();
runDaemonUninstall.mockClear();
});
it("forwards install option collisions from parent gateway command", async () => {
const gateway = createGatewayParentLikeCommand();
await gateway.parseAsync(["install", "--force", "--port", "19000", "--token", "tok_test"], {
from: "user",
});
expect(runDaemonInstall).toHaveBeenCalledWith(
expect.objectContaining({
force: true,
port: "19000",
token: "tok_test",
}),
);
});
it("forwards status auth collisions from parent gateway command", async () => {
const gateway = createGatewayParentLikeCommand();
await gateway.parseAsync(["status", "--token", "tok_status", "--password", "pw_status"], {
from: "user",
});
expect(runDaemonStatus).toHaveBeenCalledWith(
expect.objectContaining({
rpc: expect.objectContaining({
token: "tok_status",
password: "pw_status",
}),
}),
);
});
});

View File

@@ -1,4 +1,6 @@
import type { Command } from "commander";
import type { DaemonInstallOptions, GatewayRpcOpts } from "./types.js";
import { inheritOptionFromParent } from "../command-options.js";
import {
runDaemonInstall,
runDaemonRestart,
@@ -8,6 +10,31 @@ import {
runDaemonUninstall,
} from "./runners.js";
function resolveInstallOptions(
cmdOpts: DaemonInstallOptions,
command?: Command,
): DaemonInstallOptions {
const parentForce = inheritOptionFromParent<boolean>(command, "force");
const parentPort = inheritOptionFromParent<string>(command, "port");
const parentToken = inheritOptionFromParent<string>(command, "token");
return {
...cmdOpts,
force: Boolean(cmdOpts.force || parentForce),
port: cmdOpts.port ?? parentPort,
token: cmdOpts.token ?? parentToken,
};
}
function resolveRpcOptions(cmdOpts: GatewayRpcOpts, command?: Command): GatewayRpcOpts {
const parentToken = inheritOptionFromParent<string>(command, "token");
const parentPassword = inheritOptionFromParent<string>(command, "password");
return {
...cmdOpts,
token: cmdOpts.token ?? parentToken,
password: cmdOpts.password ?? parentPassword,
};
}
export function addGatewayServiceCommands(parent: Command, opts?: { statusDescription?: string }) {
parent
.command("status")
@@ -19,9 +46,9 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (cmdOpts) => {
.action(async (cmdOpts, command) => {
await runDaemonStatus({
rpc: cmdOpts,
rpc: resolveRpcOptions(cmdOpts, command),
probe: Boolean(cmdOpts.probe),
deep: Boolean(cmdOpts.deep),
json: Boolean(cmdOpts.json),
@@ -36,8 +63,8 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (cmdOpts) => {
await runDaemonInstall(cmdOpts);
.action(async (cmdOpts, command) => {
await runDaemonInstall(resolveInstallOptions(cmdOpts, command));
});
parent

View File

@@ -0,0 +1,160 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayCli = vi.fn(async () => ({ ok: true }));
const gatewayStatusCommand = vi.fn(async () => {});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../cli-utils.js", () => ({
runCommandWithRuntime: async (
_runtime: unknown,
action: () => Promise<void>,
onError: (err: unknown) => void,
) => {
try {
await action();
} catch (err) {
onError(err);
}
},
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown, runtime: unknown) => gatewayStatusCommand(opts, runtime),
}));
vi.mock("./call.js", () => ({
gatewayCallOpts: (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL")
.option("--token <token>", "Gateway token")
.option("--password <password>", "Gateway password")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false)
.option("--json", "Output JSON", false),
callGatewayCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayCli(method, opts, params),
}));
vi.mock("./run.js", () => ({
addGatewayRunCommand: (cmd: Command) =>
cmd
.option("--token <token>", "Gateway token")
.option("--password <password>", "Gateway password"),
}));
vi.mock("../daemon-cli.js", () => ({
addGatewayServiceCommands: () => undefined,
}));
vi.mock("../../commands/health.js", () => ({
formatHealthChannelLines: () => [],
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("../../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: async () => [],
}));
vi.mock("../../infra/widearea-dns.js", () => ({
resolveWideAreaDiscoveryDomain: () => undefined,
}));
vi.mock("../../terminal/health-style.js", () => ({
styleHealthChannelLine: (line: string) => line,
}));
vi.mock("../../terminal/links.js", () => ({
formatDocsLink: () => "docs.openclaw.ai/cli/gateway",
}));
vi.mock("../../terminal/theme.js", () => ({
colorize: (_rich: boolean, _fn: (value: string) => string, value: string) => value,
isRich: () => false,
theme: {
heading: (value: string) => value,
muted: (value: string) => value,
success: (value: string) => value,
},
}));
vi.mock("../../utils/usage-format.js", () => ({
formatTokenCount: () => "0",
formatUsd: () => "$0.00",
}));
vi.mock("../help-format.js", () => ({
formatHelpExamples: () => "",
}));
vi.mock("../progress.js", () => ({
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
}));
vi.mock("./discover.js", () => ({
dedupeBeacons: (beacons: unknown[]) => beacons,
parseDiscoverTimeoutMs: () => 2000,
pickBeaconHost: () => null,
pickGatewayPort: () => 18789,
renderBeaconLines: () => [],
}));
describe("gateway register option collisions", () => {
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayCli.mockClear();
gatewayStatusCommand.mockClear();
});
it("forwards --token to gateway call when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], {
from: "user",
});
expect(callGatewayCli).toHaveBeenCalledWith(
"health",
expect.objectContaining({
token: "tok_call",
}),
{},
);
});
it("forwards --token to gateway probe when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], {
from: "user",
});
expect(gatewayStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({
token: "tok_probe",
}),
defaultRuntime,
);
});
});

View File

@@ -11,6 +11,7 @@ import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { inheritOptionFromParent } from "../command-options.js";
import { addGatewayServiceCommands } from "../daemon-cli.js";
import { formatHelpExamples } from "../help-format.js";
import { withProgress } from "../progress.js";
@@ -46,6 +47,19 @@ function parseDaysOption(raw: unknown, fallback = 30): number {
return fallback;
}
function resolveGatewayRpcOptions<T extends { token?: string; password?: string }>(
opts: T,
command?: Command,
): T {
const parentToken = inheritOptionFromParent<string>(command, "token");
const parentPassword = inheritOptionFromParent<string>(command, "password");
return {
...opts,
token: opts.token ?? parentToken,
password: opts.password ?? parentPassword,
};
}
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
@@ -103,11 +117,12 @@ export function registerGatewayCli(program: Command) {
.description("Call a Gateway method")
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
.action(async (method, opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
const result = await callGatewayCli(method, rpcOpts, params);
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -125,11 +140,12 @@ export function registerGatewayCli(program: Command) {
.command("usage-cost")
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", opts, { days });
if (opts.json) {
const result = await callGatewayCli("usage.cost", rpcOpts, { days });
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -146,10 +162,11 @@ export function registerGatewayCli(program: Command) {
gateway
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const result = await callGatewayCli("health", opts);
if (opts.json) {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const result = await callGatewayCli("health", rpcOpts);
if (rpcOpts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
@@ -180,9 +197,10 @@ export function registerGatewayCli(program: Command) {
.option("--password <password>", "Gateway password (applies to all probes)")
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
.action(async (opts, command) => {
await runGatewayCommand(async () => {
await gatewayStatusCommand(opts, defaultRuntime);
const rpcOpts = resolveGatewayRpcOptions(opts, command);
await gatewayStatusCommand(rpcOpts, defaultRuntime);
});
});

View File

@@ -0,0 +1,143 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const startGatewayServer = vi.fn(async () => ({
close: vi.fn(async () => {}),
}));
const setGatewayWsLogStyle = vi.fn();
const setVerbose = vi.fn();
const forceFreePortAndWait = vi.fn(async () => ({
killed: [],
waitedMs: 0,
escalatedToSigkill: false,
}));
const ensureDevGatewayConfig = vi.fn(async () => {});
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
await start();
});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
loadConfig: () => ({}),
readConfigFileSnapshot: async () => ({ exists: false }),
resolveStateDir: () => "/tmp",
resolveGatewayPort: () => 18789,
}));
vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: (params: { authConfig?: { token?: string }; env?: NodeJS.ProcessEnv }) => ({
mode: "token",
token: params.authConfig?.token ?? params.env?.OPENCLAW_GATEWAY_TOKEN,
password: undefined,
allowTailscale: false,
}),
}));
vi.mock("../../gateway/server.js", () => ({
startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts),
}));
vi.mock("../../gateway/ws-logging.js", () => ({
setGatewayWsLogStyle: (style: string) => setGatewayWsLogStyle(style),
}));
vi.mock("../../globals.js", () => ({
setVerbose: (enabled: boolean) => setVerbose(enabled),
}));
vi.mock("../../infra/gateway-lock.js", () => ({
GatewayLockError: class GatewayLockError extends Error {},
}));
vi.mock("../../infra/ports.js", () => ({
formatPortDiagnostics: () => [],
inspectPortUsage: async () => ({ status: "free" }),
}));
vi.mock("../../logging/console.js", () => ({
setConsoleSubsystemFilter: () => undefined,
setConsoleTimestampPrefix: () => undefined,
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
info: () => undefined,
warn: () => undefined,
error: () => undefined,
}),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../command-format.js", () => ({
formatCliCommand: (cmd: string) => cmd,
}));
vi.mock("../ports.js", () => ({
forceFreePortAndWait: (port: number, opts: unknown) => forceFreePortAndWait(port, opts),
}));
vi.mock("./dev.js", () => ({
ensureDevGatewayConfig: (opts?: unknown) => ensureDevGatewayConfig(opts),
}));
vi.mock("./run-loop.js", () => ({
runGatewayLoop: (params: { start: () => Promise<unknown> }) => runGatewayLoop(params),
}));
describe("gateway run option collisions", () => {
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
startGatewayServer.mockClear();
setGatewayWsLogStyle.mockClear();
setVerbose.mockClear();
forceFreePortAndWait.mockClear();
ensureDevGatewayConfig.mockClear();
runGatewayLoop.mockClear();
});
it("forwards parent-captured options to `gateway run` subcommand", async () => {
const { addGatewayRunCommand } = await import("./run.js");
const program = new Command();
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
await program.parseAsync(
[
"gateway",
"run",
"--token",
"tok_run",
"--allow-unconfigured",
"--ws-log",
"full",
"--force",
],
{ from: "user" },
);
expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything());
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
expect.objectContaining({
auth: expect.objectContaining({
token: "tok_run",
}),
}),
);
});
});

View File

@@ -1,7 +1,8 @@
import type { Command } from "commander";
import fs from "node:fs";
import path from "node:path";
import type { Command } from "commander";
import type { GatewayAuthMode } from "../../config/config.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import {
CONFIG_PATH,
loadConfig,
@@ -11,7 +12,6 @@ import {
} from "../../config/config.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import { startGatewayServer } from "../../gateway/server.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
import { setVerbose } from "../../globals.js";
import { GatewayLockError } from "../../infra/gateway-lock.js";
@@ -20,6 +20,7 @@ import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logg
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import { inheritOptionFromParent } from "../command-options.js";
import { forceFreePortAndWait } from "../ports.js";
import { ensureDevGatewayConfig } from "./dev.js";
import { runGatewayLoop } from "./run-loop.js";
@@ -53,6 +54,50 @@ type GatewayRunOpts = {
const gatewayLog = createSubsystemLogger("gateway");
const GATEWAY_RUN_VALUE_KEYS = [
"port",
"bind",
"token",
"auth",
"password",
"tailscale",
"wsLog",
"rawStreamPath",
] as const;
const GATEWAY_RUN_BOOLEAN_KEYS = [
"tailscaleResetOnExit",
"allowUnconfigured",
"dev",
"reset",
"force",
"verbose",
"claudeCliLogs",
"compact",
"rawStream",
] as const;
function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts {
const resolved: GatewayRunOpts = { ...opts };
for (const key of GATEWAY_RUN_VALUE_KEYS) {
const inherited = inheritOptionFromParent(command, key);
if (key === "wsLog") {
// wsLog has a child default ("auto"), so prefer inherited parent CLI value when present.
resolved[key] = inherited ?? resolved[key];
continue;
}
resolved[key] = resolved[key] ?? inherited;
}
for (const key of GATEWAY_RUN_BOOLEAN_KEYS) {
const inherited = inheritOptionFromParent<boolean>(command, key);
resolved[key] = Boolean(resolved[key] || inherited);
}
return resolved;
}
async function runGatewayCommand(opts: GatewayRunOpts) {
const isDevProfile = process.env.OPENCLAW_PROFILE?.trim().toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
@@ -353,7 +398,7 @@ export function addGatewayRunCommand(cmd: Command): Command {
.option("--compact", 'Alias for "--ws-log compact"', false)
.option("--raw-stream", "Log raw model stream events to jsonl", false)
.option("--raw-stream-path <path>", "Raw stream jsonl path")
.action(async (opts) => {
await runGatewayCommand(opts);
.action(async (opts, command) => {
await runGatewayCommand(resolveGatewayRunOptions(opts, command));
});
}

View File

@@ -0,0 +1,68 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const updateCommand = vi.fn(async () => {});
const updateStatusCommand = vi.fn(async () => {});
const updateWizardCommand = vi.fn(async () => {});
const defaultRuntime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("./update-cli/update-command.js", () => ({
updateCommand: (opts: unknown) => updateCommand(opts),
}));
vi.mock("./update-cli/status.js", () => ({
updateStatusCommand: (opts: unknown) => updateStatusCommand(opts),
}));
vi.mock("./update-cli/wizard.js", () => ({
updateWizardCommand: (opts: unknown) => updateWizardCommand(opts),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("update cli option collisions", () => {
beforeEach(() => {
updateCommand.mockClear();
updateStatusCommand.mockClear();
updateWizardCommand.mockClear();
defaultRuntime.log.mockClear();
defaultRuntime.error.mockClear();
defaultRuntime.exit.mockClear();
});
it("forwards parent-captured --json/--timeout to `update status`", async () => {
const { registerUpdateCli } = await import("./update-cli.js");
const program = new Command();
registerUpdateCli(program);
await program.parseAsync(["update", "status", "--json", "--timeout", "9"], { from: "user" });
expect(updateStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
timeout: "9",
}),
);
});
it("forwards parent-captured --timeout to `update wizard`", async () => {
const { registerUpdateCli } = await import("./update-cli.js");
const program = new Command();
registerUpdateCli(program);
await program.parseAsync(["update", "wizard", "--timeout", "13"], { from: "user" });
expect(updateWizardCommand).toHaveBeenCalledWith(
expect.objectContaining({
timeout: "13",
}),
);
});
});

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { inheritOptionFromParent } from "./command-options.js";
import { formatHelpExamples } from "./help-format.js";
import {
type UpdateCommandOptions,
@@ -15,6 +16,21 @@ import { updateWizardCommand } from "./update-cli/wizard.js";
export { updateCommand, updateStatusCommand, updateWizardCommand };
export type { UpdateCommandOptions, UpdateStatusOptions, UpdateWizardOptions };
function inheritedUpdateJson(command?: Command): boolean {
return Boolean(inheritOptionFromParent<boolean>(command, "json"));
}
function inheritedUpdateTimeout(
opts: { timeout?: unknown },
command?: Command,
): string | undefined {
const timeout = opts.timeout as string | undefined;
if (timeout) {
return timeout;
}
return inheritOptionFromParent<string>(command, "timeout");
}
export function registerUpdateCli(program: Command) {
const update = program
.command("update")
@@ -89,10 +105,10 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
"after",
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}\n`,
)
.action(async (opts) => {
.action(async (opts, command) => {
try {
await updateWizardCommand({
timeout: opts.timeout as string | undefined,
timeout: inheritedUpdateTimeout(opts, command),
});
} catch (err) {
defaultRuntime.error(String(err));
@@ -118,11 +134,11 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
"Docs:",
)} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`,
)
.action(async (opts) => {
.action(async (opts, command) => {
try {
await updateStatusCommand({
json: Boolean(opts.json),
timeout: opts.timeout as string | undefined,
json: Boolean(opts.json) || inheritedUpdateJson(command),
timeout: inheritedUpdateTimeout(opts, command),
});
} catch (err) {
defaultRuntime.error(String(err));