fix(cli): preserve failure exit semantics (#112210)

This commit is contained in:
Peter Steinberger
2026-07-21 01:48:25 -07:00
committed by GitHub
parent b68b9726b6
commit 6f43c50f37
13 changed files with 544 additions and 81 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ openclaw agent --agent ops --message "Run locally" --local
- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent:<agent-id>:<session-key>`, and `--agent` must match the key's agent id when both are given. Bare non-sentinel keys scope to `--agent` when supplied, or to the configured default agent otherwise; for example `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. The literal keys `global` and `unknown` stay unscoped only when no `--agent` is supplied.
- `--json` reserves stdout for the JSON response; Gateway, plugin, and `--local` diagnostics go to stderr so scripts can parse stdout directly.
- After transient handshake retries are exhausted, a Gateway timeout or closed connection fails the command; the CLI never silently reruns the turn embedded. Transport loss is ambiguous — the Gateway may have accepted and may still finish the turn — so the stderr hint says to check `openclaw gateway status` and the session transcript before retrying or rerunning with `--local`, to avoid executing the turn twice.
- `SIGTERM`/`SIGINT` interrupt a waiting Gateway-backed request; if the Gateway already accepted the run, the CLI also sends `chat.abort` for that run id before exiting. `--local` runs receive the same signal but do not send `chat.abort`. If the internal run-dedup key already has an active run for this session, the response reports `status: "in_flight"` and the non-JSON CLI prints a stderr diagnostic instead of an empty reply. For external cron/systemd wrappers, keep a hard-kill backstop such as `timeout -k 60 600 openclaw agent ...` so the supervisor can reap the process if shutdown cannot drain.
- `SIGTERM`/`SIGINT` interrupt a waiting Gateway-backed request; if the Gateway already accepted the run, the CLI also sends `chat.abort` for that run id before exiting. `--local` runs receive the same signal but do not send `chat.abort`. A launcher child that terminates from the first forwarded `SIGINT` or `SIGTERM` exits with status 130 or 143, respectively. If the internal run-dedup key already has an active run for this session, the response reports `status: "in_flight"` and the non-JSON CLI prints a stderr diagnostic instead of an empty reply. For external cron/systemd wrappers, keep a hard-kill backstop such as `timeout -k 60 600 openclaw agent ...` so the supervisor can reap the process if shutdown cannot drain.
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), never resolved secret plaintext. Marker writes come from the active source config snapshot, not from resolved runtime secret values.
## JSON delivery status
+15 -3
View File
@@ -140,6 +140,8 @@ const runRespawnedChild = (command, args, env) => {
let signalExitTimer = null;
let signalForceKillTimer = null;
let signalHardExitTimer = null;
let firstForwardedSignal = null;
let hardKillBackstopStarted = false;
const detach = () => {
for (const [signal, listener] of listeners) {
process.off(signal, listener);
@@ -172,6 +174,7 @@ const runRespawnedChild = (command, args, env) => {
// Best-effort shutdown fallback.
}
signalForceKillTimer = setTimeout(() => {
hardKillBackstopStarted = true;
forceKillChild();
signalHardExitTimer = setTimeout(() => {
process.exit(1);
@@ -180,7 +183,8 @@ const runRespawnedChild = (command, args, env) => {
}, respawnSignalForceKillGraceMs);
signalForceKillTimer.unref?.();
};
const scheduleParentExit = () => {
const scheduleParentExit = (signal) => {
firstForwardedSignal ??= signal;
if (signalExitTimer) {
return;
}
@@ -196,7 +200,7 @@ const runRespawnedChild = (command, args, env) => {
} catch {
// Best-effort signal forwarding.
}
scheduleParentExit();
scheduleParentExit(signal);
};
try {
process.on(signal, listener);
@@ -208,7 +212,15 @@ const runRespawnedChild = (command, args, env) => {
child.once("exit", (code, signal) => {
detach();
if (signal) {
process.exit(1);
const forwardedSignalExitCode =
!hardKillBackstopStarted && signal === firstForwardedSignal
? signal === "SIGINT"
? 130
: signal === "SIGTERM"
? 143
: undefined
: undefined;
process.exit(forwardedSignalExitCode ?? 1);
}
process.exit(code ?? 1);
});
@@ -1,4 +1,5 @@
// Gateway run option collision tests cover gateway run flag registration boundaries.
import { createServer } from "node:http";
import { Command } from "commander";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CONFIG_AUDIT_STORE_LABEL } from "../../config/io.audit.js";
@@ -1595,6 +1596,46 @@ describe("gateway run option collisions", () => {
expect(writeDiagnosticStabilityBundleForFailureSync).not.toHaveBeenCalled();
});
it.each([
"gateway already running (pid 4242); lock timeout after 5000ms",
"another gateway instance is already listening on ws://127.0.0.1",
])("exits 1 for unmanaged healthy-port lock conflicts: %s", async (message) => {
const healthyGateway = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, status: "live" }));
});
await new Promise<void>((resolve) => {
healthyGateway.listen(0, "127.0.0.1", resolve);
});
const address = healthyGateway.address();
if (!address || typeof address === "string") {
throw new Error("expected TCP server address");
}
const port = address.port;
configState.snapshot = {
config: { gateway: { port } },
exists: false,
sourceConfig: {},
valid: true,
};
const err = Object.assign(new Error(`${message}:${port}`), {
name: "GatewayLockError",
});
startGatewayServer.mockRejectedValueOnce(err);
try {
await withEnvAsync(withoutSupervisorEnv, async () => {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:1",
);
});
} finally {
await new Promise<void>((resolve, reject) => {
healthyGateway.close((closeError) => (closeError ? reject(closeError) : resolve()));
});
}
});
it("blocks startup when the observed snapshot loses gateway.mode", async () => {
configState.cfg = {
gateway: {
+57 -21
View File
@@ -4,6 +4,14 @@ import { describe, expect, it, vi } from "vitest";
import { GatewayLockError } from "../../infra/gateway-lock.js";
import { testing } from "./run.test-support.js";
const loadGatewayTlsRuntimeMock = vi.hoisted(() =>
vi.fn(async () => ({ enabled: false, required: true })),
);
vi.mock("../../infra/tls/gateway.js", () => ({
loadGatewayTlsRuntime: loadGatewayTlsRuntimeMock,
}));
function createLogger() {
return {
info: vi.fn(),
@@ -61,26 +69,28 @@ describe("supervised gateway lock recovery", () => {
});
const probeHealth = vi.fn(async () => true);
await expect(
testing.runGatewayLoopWithSupervisedLockRecovery({
let failure: unknown;
try {
await testing.runGatewayLoopWithSupervisedLockRecovery({
startLoop,
supervisor: "systemd",
port: 18789,
healthHost: "127.0.0.1",
log: createLogger(),
probeHealth,
}),
).rejects.toThrow("exiting with code 78 to prevent a systemd Restart=always loop");
});
} catch (err) {
failure = err;
}
expect(failure).toMatchObject({
message: expect.stringContaining(
"exiting with code 78 to prevent a systemd Restart=always loop",
),
});
expect(startLoop).toHaveBeenCalledTimes(1);
expect(probeHealth).toHaveBeenCalledWith({ host: "127.0.0.1", port: 18789 });
expect(
testing.resolveGatewayLockErrorExitCode(
new GatewayLockError("gateway already running under systemd; existing gateway is healthy"),
"systemd",
true,
),
).toBe(78);
expect(testing.resolveGatewayLockErrorExitCode(failure)).toBe(78);
});
it("bounds supervised retries when the existing gateway stays unhealthy", async () => {
@@ -92,8 +102,9 @@ describe("supervised gateway lock recovery", () => {
now += ms;
});
await expect(
testing.runGatewayLoopWithSupervisedLockRecovery({
let failure: unknown;
try {
await testing.runGatewayLoopWithSupervisedLockRecovery({
startLoop,
supervisor: "systemd",
port: 18789,
@@ -104,11 +115,16 @@ describe("supervised gateway lock recovery", () => {
sleep,
retryMs: 5,
timeoutMs: 12,
}),
).rejects.toThrow(
"gateway already running under systemd; existing gateway did not become healthy after 12ms",
);
});
} catch (err) {
failure = err;
}
expect(failure).toMatchObject({
message:
"gateway already running under systemd; existing gateway did not become healthy after 12ms",
});
expect(testing.resolveGatewayLockErrorExitCode(failure)).toBe(1);
expect(startLoop).toHaveBeenCalledTimes(4);
expect(sleep).toHaveBeenNthCalledWith(1, 5);
expect(sleep).toHaveBeenNthCalledWith(2, 5);
@@ -149,11 +165,31 @@ describe("supervised gateway lock recovery", () => {
expect(sleep).toHaveBeenNthCalledWith(3, 2);
});
it("requires a confirmed healthy gateway for unmanaged duplicate starts", () => {
const err = new GatewayLockError("another gateway instance is already listening");
it.each(["gateway already running", "another gateway instance is already listening"])(
"uses exit 1 for unmanaged lock errors: %s",
(message) => {
expect(testing.resolveGatewayLockErrorExitCode(new GatewayLockError(message))).toBe(1);
},
);
expect(testing.resolveGatewayLockErrorExitCode(err, null, false)).toBe(1);
expect(testing.resolveGatewayLockErrorExitCode(err, null, true)).toBe(0);
it("retries non-mutating TLS fingerprint loads until certificate material is ready", async () => {
loadGatewayTlsRuntimeMock.mockClear();
const probeHealth = testing.createConfiguredGatewayHealthProbe({
gateway: { tls: { enabled: true, autoGenerate: true } },
});
await expect(probeHealth({ host: "127.0.0.1", port: 18789 })).resolves.toBe(false);
await expect(probeHealth({ host: "127.0.0.1", port: 18789 })).resolves.toBe(false);
expect(loadGatewayTlsRuntimeMock).toHaveBeenCalledTimes(2);
expect(loadGatewayTlsRuntimeMock).toHaveBeenNthCalledWith(1, {
enabled: true,
autoGenerate: false,
});
expect(loadGatewayTlsRuntimeMock).toHaveBeenNthCalledWith(2, {
enabled: true,
autoGenerate: false,
});
});
it("recognizes only the OpenClaw health response", () => {
+10 -7
View File
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
import "./run.js";
@@ -7,6 +8,9 @@ type GatewayRunTestLogger = {
};
type GatewayRunTestApi = {
createConfiguredGatewayHealthProbe(
cfg: OpenClawConfig,
): (params: { host: string; port: number }) => Promise<boolean>;
isGatewayHealthzResponse(statusCode: number | undefined, body: string): boolean;
normalizeGatewayHealthProbeHost(host: string): string;
probeGatewayHealthz(params: {
@@ -15,11 +19,7 @@ type GatewayRunTestApi = {
timeoutMs?: number;
tlsFingerprint?: string;
}): Promise<boolean>;
resolveGatewayLockErrorExitCode(
err: unknown,
supervisor: RespawnSupervisor | null,
healthyGatewayConfirmed: boolean,
): number;
resolveGatewayLockErrorExitCode(err: unknown): number;
resolveGatewayStartupFailureExitCode(err: unknown): number;
runGatewayLoopWithSupervisedLockRecovery(params: {
startLoop: () => Promise<void>;
@@ -42,6 +42,9 @@ function getTestApi(): GatewayRunTestApi {
}
export const testing: GatewayRunTestApi = {
createConfiguredGatewayHealthProbe(cfg) {
return getTestApi().createConfiguredGatewayHealthProbe(cfg);
},
isGatewayHealthzResponse(statusCode, body) {
return getTestApi().isGatewayHealthzResponse(statusCode, body);
},
@@ -51,8 +54,8 @@ export const testing: GatewayRunTestApi = {
async probeGatewayHealthz(params) {
return await getTestApi().probeGatewayHealthz(params);
},
resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed) {
return getTestApi().resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed);
resolveGatewayLockErrorExitCode(err) {
return getTestApi().resolveGatewayLockErrorExitCode(err);
},
resolveGatewayStartupFailureExitCode(err) {
return getTestApi().resolveGatewayStartupFailureExitCode(err);
+40 -33
View File
@@ -458,15 +458,18 @@ function isGatewayAlreadyRunningLockError(err: unknown): boolean {
);
}
function resolveGatewayLockErrorExitCode(
err: unknown,
supervisor: RespawnSupervisor | null,
healthyGatewayConfirmed: boolean,
): number {
if (supervisor === "systemd" && isGatewayAlreadyRunningLockError(err)) {
return EXIT_CONFIG_ERROR;
class SupervisedGatewayLockError extends GatewayLockError {
constructor(
message: string,
cause: unknown,
readonly exitCode: 1 | typeof EXIT_CONFIG_ERROR,
) {
super(message, cause);
}
return healthyGatewayConfirmed && isGatewayAlreadyRunningLockError(err) ? 0 : 1;
}
function resolveGatewayLockErrorExitCode(err: unknown): number {
return err instanceof SupervisedGatewayLockError ? err.exitCode : 1;
}
function resolveGatewayStartupFailureExitCode(err: unknown): number {
@@ -566,6 +569,28 @@ async function probeGatewayHealthz(params: {
});
}
function createConfiguredGatewayHealthProbe(cfg: OpenClawConfig) {
const tlsConfig = cfg.gateway?.tls;
let tlsFingerprint: string | undefined;
return async (params: { host: string; port: number }): Promise<boolean> => {
if (tlsConfig?.enabled !== true) {
return await probeGatewayHealthz(params);
}
if (!tlsFingerprint) {
const gatewayTls = await import("../../infra/tls/gateway.js")
.then(({ loadGatewayTlsRuntime }) =>
loadGatewayTlsRuntime({ ...tlsConfig, autoGenerate: false }),
)
.catch(() => undefined);
tlsFingerprint = gatewayTls?.fingerprintSha256;
}
if (!tlsFingerprint) {
return false;
}
return await probeGatewayHealthz({ ...params, tlsFingerprint });
};
}
async function runGatewayLoopWithSupervisedLockRecovery(params: {
startLoop: () => Promise<void>;
supervisor: RespawnSupervisor | null;
@@ -607,9 +632,10 @@ async function runGatewayLoopWithSupervisedLockRecovery(params: {
if (await probeHealth({ host: params.healthHost, port: params.port })) {
if (supervisor === "systemd") {
throw new GatewayLockError(
throw new SupervisedGatewayLockError(
"gateway already running under systemd; existing gateway is healthy, exiting with code 78 to prevent a systemd Restart=always loop",
err,
EXIT_CONFIG_ERROR,
);
}
params.log.info(
@@ -620,9 +646,10 @@ async function runGatewayLoopWithSupervisedLockRecovery(params: {
const elapsedMs = now() - startedAt;
if (elapsedMs >= timeoutMs) {
throw new GatewayLockError(
throw new SupervisedGatewayLockError(
`gateway already running under ${supervisor}; existing gateway did not become healthy after ${timeoutMs}ms`,
err,
1,
);
}
@@ -1162,6 +1189,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
port,
healthHost,
log: gatewayLog,
probeHealth: createConfiguredGatewayHealthProbe(cfg),
});
} catch (err) {
if (isGatewayLockError(err)) {
@@ -1182,29 +1210,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
}
const { maybeExplainGatewayServiceStop } = await import("./shared.js");
await maybeExplainGatewayServiceStop();
// An occupied port is only a healthy duplicate when the listener proves
// the OpenClaw liveness contract. Arbitrary HTTP listeners must fail.
const gatewayTls =
isGatewayAlreadyRunningLockError(err) && cfg.gateway?.tls?.enabled === true
? await import("../../infra/tls/gateway.js")
.then(({ loadGatewayTlsRuntime }) => loadGatewayTlsRuntime(cfg.gateway?.tls))
.catch(() => undefined)
: undefined;
const canProbeGateway =
cfg.gateway?.tls?.enabled !== true || Boolean(gatewayTls?.fingerprintSha256);
const healthyGatewayConfirmed =
isGatewayAlreadyRunningLockError(err) &&
canProbeGateway &&
(await probeGatewayHealthz({
host: healthHost,
port,
...(gatewayTls?.fingerprintSha256
? { tlsFingerprint: gatewayTls.fingerprintSha256 }
: {}),
}));
defaultRuntime.exit(
resolveGatewayLockErrorExitCode(err, supervisor, healthyGatewayConfirmed),
);
defaultRuntime.exit(resolveGatewayLockErrorExitCode(err));
return;
}
if (isInvalidConfigError(err)) {
@@ -1249,6 +1255,7 @@ export async function runGatewayCommand(
}
const testing = {
createConfiguredGatewayHealthProbe,
isGatewayHealthzResponse,
normalizeGatewayHealthProbeHost,
probeGatewayHealthz,
+33 -4
View File
@@ -1,4 +1,4 @@
// Process coverage for help rendering without loading live Gateway transports.
// Process coverage for CLI help exits and route-first fallback validation.
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
@@ -52,7 +52,7 @@ registerHooks({
return { root, stateDir, configPath, tlsImportGuardPath, keepAlivePath };
}
async function runHelpProcess(params: {
async function runCliProcess(params: {
args: string[];
forbidTlsImport?: boolean;
keepAlive?: boolean;
@@ -90,12 +90,26 @@ async function runHelpProcess(params: {
);
}
type CliProcessFailure = Error & {
code?: number | string;
stderr?: string;
};
async function runCliProcessExpectFailure(args: string[]): Promise<CliProcessFailure> {
try {
await runCliProcess({ args });
} catch (error) {
return error as CliProcessFailure;
}
throw new Error(`expected CLI process failure for ${args.join(" ")}`);
}
describe("CLI help process exit", () => {
it.each([
{ args: ["--help"], usage: "Usage: openclaw [options] [command]" },
{ args: ["path", "--help"], usage: "Usage: openclaw path [options] [command]" },
])("exits promptly after $args", async ({ args, usage }) => {
const result = await runHelpProcess({ args, forbidTlsImport: true });
const result = await runCliProcess({ args, forbidTlsImport: true });
expect(result.stderr).toBe("");
expect(result.stdout).toContain(usage);
@@ -103,9 +117,24 @@ describe("CLI help process exit", () => {
it.each(LAZY_GROUP_HELP_CASES)("exits promptly after $group --help", async (testCase) => {
const { group, usageCommand } = testCase;
const result = await runHelpProcess({ args: [group, "--help"], keepAlive: true });
const result = await runCliProcess({ args: [group, "--help"], keepAlive: true });
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Usage: openclaw ${usageCommand} [options] [command]`);
});
});
describe("route-first CLI process rejection", () => {
it.each([
{ name: "health", args: ["health", "--wat"], option: "--wat" },
{ name: "status", args: ["status", "--wat"], option: "--wat" },
{ name: "sessions", args: ["sessions", "--wat"], option: "--wat" },
{ name: "agents list", args: ["agents", "list", "--wat"], option: "--wat" },
{ name: "bare agents", args: ["agents", "--wat"], option: "--wat" },
])("rejects unknown $name options with a nonzero exit", async ({ args, option }) => {
const failure = await runCliProcessExpectFailure(args);
expect(failure.code).toBe(1);
expect(failure.stderr).toContain(`does not recognize option "${option}"`);
});
});
+127
View File
@@ -2,6 +2,8 @@
import { describe, expect, it } from "vitest";
import {
parseAgentsListRouteArgs,
parseChannelsListRouteArgs,
parseChannelsStatusRouteArgs,
parseConfigGetRouteArgs,
parseConfigUnsetRouteArgs,
parseGatewayStatusRouteArgs,
@@ -86,6 +88,101 @@ describe("route-args", () => {
});
});
it.each([
{
name: "health unknown flag",
parse: parseHealthRouteArgs,
argv: ["node", "openclaw", "health", "--wat"],
},
{
name: "health stray positional",
parse: parseHealthRouteArgs,
argv: ["node", "openclaw", "health", "extra"],
},
{
name: "health flag terminator",
parse: parseHealthRouteArgs,
argv: ["node", "openclaw", "health", "--", "--json"],
},
{
name: "status malformed arity",
parse: parseStatusRouteArgs,
argv: ["node", "openclaw", "status", "--timeout"],
},
{
name: "status unknown flag",
parse: parseStatusRouteArgs,
argv: ["node", "openclaw", "status", "--wat"],
},
{
name: "sessions stray subcommand",
parse: parseSessionsRouteArgs,
argv: ["node", "openclaw", "sessions", "cleanup"],
},
{
name: "sessions unknown flag",
parse: parseSessionsRouteArgs,
argv: ["node", "openclaw", "sessions", "--wat"],
},
{
name: "sessions flag terminator",
parse: parseSessionsRouteArgs,
argv: ["node", "openclaw", "sessions", "--", "--json"],
},
{
name: "agents list stray positional",
parse: parseAgentsListRouteArgs,
argv: ["node", "openclaw", "agents", "list", "extra"],
},
{
name: "agents list unknown flag",
parse: parseAgentsListRouteArgs,
argv: ["node", "openclaw", "agents", "list", "--wat"],
},
{
name: "agents list flag terminator",
parse: parseAgentsListRouteArgs,
argv: ["node", "openclaw", "agents", "list", "--", "--json"],
},
{
name: "bare agents unknown flag",
parse: parseAgentsListRouteArgs,
argv: ["node", "openclaw", "agents", "--wat"],
},
])("defers unsupported routed argv: $name", ({ parse, argv }) => {
expect(parse(argv)).toBeNull();
});
it("preserves equals forms and root options on routed argv", () => {
expect(
parseHealthRouteArgs([
"node",
"openclaw",
"--profile",
"work",
"health",
"--timeout=5000",
"--json",
]),
).toEqual({ json: true, verbose: false, timeoutMs: 5000 });
expect(
parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent=default", "--limit=25"]),
).toMatchObject({ agent: "default", limit: "25" });
expect(
parseAgentsListRouteArgs([
"node",
"openclaw",
"--log-level=debug",
"agents",
"list",
"--json",
]),
).toEqual({ json: true, bindings: false });
expect(
parseAgentsListRouteArgs(["node", "openclaw", "agents", "--json", "--bindings"]),
).toEqual({ json: true, bindings: true });
});
it("parses gateway status route args and rejects probe-only ssh flags", () => {
expect(
parseGatewayStatusRouteArgs([
@@ -282,4 +379,34 @@ describe("route-args", () => {
parseModelsStatusRouteArgs(["node", "openclaw", "models", "status", "--probe-profile"]),
).toBeNull();
});
it.each([
{
name: "gateway status",
parse: parseGatewayStatusRouteArgs,
argv: ["node", "openclaw", "gateway", "status", "--wat"],
},
{
name: "models list",
parse: parseModelsListRouteArgs,
argv: ["node", "openclaw", "models", "list", "--wat"],
},
{
name: "models status",
parse: parseModelsStatusRouteArgs,
argv: ["node", "openclaw", "models", "status", "--wat"],
},
{
name: "channels list",
parse: parseChannelsListRouteArgs,
argv: ["node", "openclaw", "channels", "list", "--wat"],
},
{
name: "channels status",
parse: parseChannelsStatusRouteArgs,
argv: ["node", "openclaw", "channels", "status", "--wat"],
},
])("defers unknown options for sibling routed parser: $name", ({ parse, argv }) => {
expect(parse(argv)).toBeNull();
});
});
+107 -8
View File
@@ -51,6 +51,22 @@ function parseRepeatedFlagValues(argv: string[], name: string): string[] | null
return values;
}
type RoutedCommandArgShape = {
commandPath: string[];
booleanFlags?: string[];
valueFlags?: string[];
};
function getRoutedCommandPositionals(
argv: string[],
shape: RoutedCommandArgShape,
): string[] | null {
if (argv.slice(2).includes("--")) {
return null;
}
return getCommandPositionalsWithRootOptions(argv, shape);
}
function parseSinglePositional(
argv: string[],
params: {
@@ -58,7 +74,7 @@ function parseSinglePositional(
booleanFlags?: string[];
},
): string | null {
const positionals = getCommandPositionalsWithRootOptions(argv, params);
const positionals = getRoutedCommandPositionals(argv, params);
if (!positionals || positionals.length !== 1) {
return null;
}
@@ -67,6 +83,14 @@ function parseSinglePositional(
/** Parse `openclaw health` flags for the route-first status family. */
export function parseHealthRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["health"],
booleanFlags: ["--json", "--verbose", "--debug"],
valueFlags: ["--timeout"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) {
return null;
@@ -80,6 +104,14 @@ export function parseHealthRouteArgs(argv: string[]) {
/** Parse `openclaw status` flags without registering the full command tree. */
export function parseStatusRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["status"],
booleanFlags: ["--json", "--deep", "--all", "--usage", "--verbose", "--debug"],
valueFlags: ["--timeout"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) {
return null;
@@ -96,6 +128,14 @@ export function parseStatusRouteArgs(argv: string[]) {
/** Parse `openclaw gateway status` RPC-only flags accepted by the fast route. */
export function parseGatewayStatusRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["gateway", "status"],
booleanFlags: ["--deep", "--json", "--require-rpc", "--no-probe", "--ssh-auto"],
valueFlags: ["--url", "--token", "--password", "--timeout", "--ssh", "--ssh-identity"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const url = parseOptionalFlagValue(argv, "--url");
if (!url.ok) {
return null;
@@ -140,6 +180,14 @@ export function parseGatewayStatusRouteArgs(argv: string[]) {
/** Parse `openclaw sessions` filters for JSON/list route execution. */
export function parseSessionsRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["sessions"],
booleanFlags: ["--json", "--all-agents"],
valueFlags: ["--agent", "--store", "--active", "--limit"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const agent = parseOptionalFlagValue(argv, "--agent");
if (!agent.ok) {
return null;
@@ -168,10 +216,23 @@ export function parseSessionsRouteArgs(argv: string[]) {
/** Parse `openclaw agents list` display switches for route-first execution. */
export function parseAgentsListRouteArgs(argv: string[]) {
return {
json: hasFlag(argv, "--json"),
bindings: hasFlag(argv, "--bindings"),
};
const listPositionals = getRoutedCommandPositionals(argv, {
commandPath: ["agents", "list"],
booleanFlags: ["--json", "--bindings"],
});
if (listPositionals && listPositionals.length === 0) {
return {
json: hasFlag(argv, "--json"),
bindings: hasFlag(argv, "--bindings"),
};
}
const aliasPositionals = getRoutedCommandPositionals(argv, {
commandPath: ["agents"],
booleanFlags: ["--json", "--bindings"],
});
return aliasPositionals?.length === 0
? { json: hasFlag(argv, "--json"), bindings: hasFlag(argv, "--bindings") }
: null;
}
/** Parse `openclaw config get <path>` while preserving root option handling. */
@@ -210,6 +271,14 @@ export function parseConfigUnsetRouteArgs(argv: string[]) {
/** Parse `openclaw models list` filters for the lightweight model catalog route. */
export function parseModelsListRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["models", "list"],
booleanFlags: ["--all", "--local", "--json", "--plain"],
valueFlags: ["--provider"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const provider = parseOptionalFlagValue(argv, "--provider");
if (!provider.ok) {
return null;
@@ -225,6 +294,21 @@ export function parseModelsListRouteArgs(argv: string[]) {
/** Parse `openclaw models status` probe controls for the route-first status path. */
export function parseModelsStatusRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["models", "status"],
booleanFlags: ["--json", "--plain", "--check", "--probe"],
valueFlags: [
"--probe-provider",
"--probe-timeout",
"--probe-concurrency",
"--probe-max-tokens",
"--probe-profile",
"--agent",
],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const probeProvider = parseOptionalFlagValue(argv, "--probe-provider");
if (!probeProvider.ok) {
return null;
@@ -271,6 +355,13 @@ export function parseModelsStatusRouteArgs(argv: string[]) {
/** Parse `openclaw channels list` display flags for the route-first list path. */
export function parseChannelsListRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["channels", "list"],
booleanFlags: ["--json", "--all"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
return {
json: hasFlag(argv, "--json"),
all: hasFlag(argv, "--all"),
@@ -279,6 +370,14 @@ export function parseChannelsListRouteArgs(argv: string[]) {
/** Parse `openclaw channels status` probe flags without full CLI registration. */
export function parseChannelsStatusRouteArgs(argv: string[]) {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["channels", "status"],
booleanFlags: ["--json", "--probe"],
valueFlags: ["--timeout", "--channel"],
});
if (!positionals || positionals.length !== 0) {
return null;
}
const timeout = parseOptionalFlagValue(argv, "--timeout");
const channel = parseOptionalFlagValue(argv, "--channel");
if (!timeout.ok) {
@@ -300,7 +399,7 @@ export function parsePluginsListRouteArgs(argv: string[]) {
if (!hasFlag(argv, "--json")) {
return null;
}
const positionals = getCommandPositionalsWithRootOptions(argv, {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["plugins", "list"],
booleanFlags: ["--json", "--enabled", "--verbose"],
});
@@ -318,7 +417,7 @@ function parseTasksListRouteArgsForCommandPath(argv: string[], commandPath: stri
if (!hasFlag(argv, "--json")) {
return null;
}
const positionals = getCommandPositionalsWithRootOptions(argv, {
const positionals = getRoutedCommandPositionals(argv, {
commandPath,
booleanFlags: ["--json"],
valueFlags: ["--runtime", "--status"],
@@ -354,7 +453,7 @@ export function parseTasksAuditRouteArgs(argv: string[]) {
if (!hasFlag(argv, "--json")) {
return null;
}
const positionals = getCommandPositionalsWithRootOptions(argv, {
const positionals = getRoutedCommandPositionals(argv, {
commandPath: ["tasks", "audit"],
booleanFlags: ["--json"],
valueFlags: ["--severity", "--code", "--limit"],
+16
View File
@@ -284,6 +284,22 @@ describe("program routes", () => {
await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]);
});
it.each([
{ path: ["health"], argv: ["node", "openclaw", "health", "--wat"] },
{ path: ["status"], argv: ["node", "openclaw", "status", "--wat"] },
{ path: ["sessions"], argv: ["node", "openclaw", "sessions", "--wat"] },
{
path: ["agents", "list"],
argv: ["node", "openclaw", "agents", "list", "--wat"],
},
{ path: ["agents"], argv: ["node", "openclaw", "agents", "--wat"] },
])(
"returns false instead of handling unknown routed option for $path",
async ({ path, argv }) => {
await expectRunFalse(path, argv);
},
);
it("routes status --json through the lean JSON command", async () => {
const route = expectRoute(["status"]);
await expect(
+33 -1
View File
@@ -50,10 +50,41 @@ describe("runRespawnChildWithSignalBridge", () => {
});
});
it.each([
{ signal: "SIGINT" as const, laterSignal: "SIGTERM" as const, exitCode: 130 },
{ signal: "SIGTERM" as const, laterSignal: "SIGINT" as const, exitCode: 143 },
])("exits $exitCode when the child exits by forwarded $signal", (testCase) => {
const { child } = createChild(2345);
const exit = vi.fn();
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
runRespawnChildWithSignalBridge({
command: "/usr/bin/node",
args: ["/repo/openclaw/dist/entry.js"],
env: {},
runtime: {
spawn: vi.fn(() => child) as unknown as typeof spawn,
attachChildProcessBridge: vi.fn((_child, options) => {
onSignal = options?.onSignal;
return { detach: vi.fn() };
}),
exit: exit as unknown as (code?: number) => never,
},
onError: vi.fn(),
});
onSignal?.(testCase.signal);
onSignal?.(testCase.laterSignal);
child.emit("exit", null, testCase.signal);
expect(exit).toHaveBeenCalledWith(testCase.exitCode);
});
it("signals detached respawn process groups after forwarded signal grace", () => {
vi.useFakeTimers();
const { child, kill } = createChild(2468);
const spawnChild = vi.fn(() => child);
const exit = vi.fn();
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
try {
@@ -69,7 +100,7 @@ describe("runRespawnChildWithSignalBridge", () => {
onSignal = options?.onSignal;
return { detach: vi.fn() };
}),
exit: vi.fn() as unknown as (code?: number) => never,
exit: exit as unknown as (code?: number) => never,
},
onError: vi.fn(),
});
@@ -98,6 +129,7 @@ describe("runRespawnChildWithSignalBridge", () => {
}
child.emit("exit", null, "SIGKILL");
expect(exit).toHaveBeenCalledWith(1);
} finally {
vi.useRealTimers();
}
+14 -2
View File
@@ -38,6 +38,8 @@ export function runRespawnChildWithSignalBridge(params: {
let signalForceKillTimer: NodeJS.Timeout | undefined;
let signalHardExitTimer: NodeJS.Timeout | undefined;
let parentSignalReceived = false;
let firstForwardedSignal: NodeJS.Signals | undefined;
let hardKillBackstopStarted = false;
const clearSignalTimers = (): void => {
if (signalExitTimer) {
clearTimeout(signalExitTimer);
@@ -73,6 +75,7 @@ export function runRespawnChildWithSignalBridge(params: {
// Best-effort shutdown fallback.
}
signalForceKillTimer = setTimeout(() => {
hardKillBackstopStarted = true;
forceKillChild();
signalHardExitTimer = setTimeout(() => {
runtime.exit(1);
@@ -81,8 +84,9 @@ export function runRespawnChildWithSignalBridge(params: {
}, RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS);
signalForceKillTimer.unref?.();
};
const scheduleParentExit = (): void => {
const scheduleParentExit = (signal: NodeJS.Signals): void => {
parentSignalReceived = true;
firstForwardedSignal ??= signal;
if (signalExitTimer) {
return;
}
@@ -102,7 +106,15 @@ export function runRespawnChildWithSignalBridge(params: {
}
clearSignalTimers();
if (signal) {
runtime.exit(1);
const forwardedSignalExitCode =
!hardKillBackstopStarted && signal === firstForwardedSignal
? signal === "SIGINT"
? 130
: signal === "SIGTERM"
? 143
: undefined
: undefined;
runtime.exit(forwardedSignalExitCode ?? 1);
return;
}
runtime.exit(code ?? 1);
+50 -1
View File
@@ -863,6 +863,55 @@ describe("openclaw launcher", () => {
},
);
it.runIf(process.platform !== "win32").each([
{ signal: "SIGINT" as const, exitCode: 130 },
{ signal: "SIGTERM" as const, exitCode: 143 },
])("exits $exitCode when the respawn child terminates from $signal", async (testCase) => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await addGitMarker(fixtureRoot);
const childInfoPath = path.join(fixtureRoot, "child-info.json");
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
[
'import { writeFileSync } from "node:fs";',
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
'process.title = "openclaw-launcher-default-signal-test-child";',
"setInterval(() => {}, 1000);",
"",
].join("\n"),
"utf8",
);
const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], {
cwd: fixtureRoot,
env: launcherEnv({
NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"),
}),
stdio: "ignore",
});
let respawnChildPid: number | undefined;
try {
const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000);
respawnChildPid = childInfo.pid;
launcher.kill(testCase.signal);
await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({
code: testCase.exitCode,
signal: null,
});
expect(isProcessAlive(respawnChildPid)).toBe(false);
} finally {
if (isProcessAlive(respawnChildPid)) {
process.kill(respawnChildPid!, "SIGKILL");
}
if (isProcessAlive(launcher.pid)) {
process.kill(launcher.pid!, "SIGKILL");
}
}
});
it.runIf(process.platform !== "win32")(
"exits after SIGTERM when the respawn child ignores the forwarded signal",
async () => {
@@ -873,9 +922,9 @@ describe("openclaw launcher", () => {
path.join(fixtureRoot, "dist", "entry.js"),
[
'import { writeFileSync } from "node:fs";',
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
'process.title = "openclaw-launcher-sigterm-ignore-test-child";',
'process.on("SIGTERM", () => {});',
`writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`,
"setInterval(() => {}, 1000);",
"",
].join("\n"),