feat(cli): support targeting running containerized openclaw instances (#52651)

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley 2026-03-24 10:17:17 -04:00 committed by GitHub
parent dd11bdd003
commit 91adc5e718
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1484 additions and 39 deletions

View File

@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
### Fixes

View File

@ -165,7 +165,17 @@ describe("argv helpers", () => {
it("extracts command path while skipping known root option values", () => {
expect(
getCommandPathWithRootOptions(
["node", "openclaw", "--profile", "work", "--no-color", "config", "validate"],
[
"node",
"openclaw",
"--profile",
"work",
"--container",
"demo",
"--no-color",
"config",
"validate",
],
2,
),
).toEqual(["config", "validate"]);

View File

@ -2,8 +2,11 @@ import { replaceCliName, resolveCliName } from "./cli-name.js";
import { normalizeProfileName } from "./profile-utils.js";
const CLI_PREFIX_RE = /^(?:pnpm|npm|bunx|npx)\s+openclaw\b|^openclaw\b/;
const CONTAINER_FLAG_RE = /(?:^|\s)--container(?:\s|=|$)/;
const PROFILE_FLAG_RE = /(?:^|\s)--profile(?:\s|=|$)/;
const DEV_FLAG_RE = /(?:^|\s)--dev(?:\s|$)/;
const UPDATE_COMMAND_RE =
/^(?:pnpm|npm|bunx|npx)\s+openclaw\b.*(?:^|\s)update(?:\s|$)|^openclaw\b.*(?:^|\s)update(?:\s|$)/;
export function formatCliCommand(
command: string,
@ -11,15 +14,32 @@ export function formatCliCommand(
): string {
const cliName = resolveCliName();
const normalizedCommand = replaceCliName(command, cliName);
const container = env.OPENCLAW_CONTAINER_HINT?.trim();
const profile = normalizeProfileName(env.OPENCLAW_PROFILE);
if (!profile) {
if (!container && !profile) {
return normalizedCommand;
}
if (!CLI_PREFIX_RE.test(normalizedCommand)) {
return normalizedCommand;
}
if (PROFILE_FLAG_RE.test(normalizedCommand) || DEV_FLAG_RE.test(normalizedCommand)) {
const additions: string[] = [];
if (
container &&
!CONTAINER_FLAG_RE.test(normalizedCommand) &&
!UPDATE_COMMAND_RE.test(normalizedCommand)
) {
additions.push(`--container ${container}`);
}
if (
!container &&
profile &&
!PROFILE_FLAG_RE.test(normalizedCommand) &&
!DEV_FLAG_RE.test(normalizedCommand)
) {
additions.push(`--profile ${profile}`);
}
if (additions.length === 0) {
return normalizedCommand;
}
return normalizedCommand.replace(CLI_PREFIX_RE, (match) => `${match} --profile ${profile}`);
return normalizedCommand.replace(CLI_PREFIX_RE, (match) => `${match} ${additions.join(" ")}`);
}

View File

@ -0,0 +1,623 @@
import { describe, expect, it, vi } from "vitest";
import {
maybeRunCliInContainer,
parseCliContainerArgs,
resolveCliContainerTarget,
} from "./container-target.js";
describe("parseCliContainerArgs", () => {
it("extracts a root --container flag before the command", () => {
expect(
parseCliContainerArgs(["node", "openclaw", "--container", "demo", "status", "--deep"]),
).toEqual({
ok: true,
container: "demo",
argv: ["node", "openclaw", "status", "--deep"],
});
});
it("accepts the equals form", () => {
expect(parseCliContainerArgs(["node", "openclaw", "--container=demo", "health"])).toEqual({
ok: true,
container: "demo",
argv: ["node", "openclaw", "health"],
});
});
it("rejects a missing container value", () => {
expect(parseCliContainerArgs(["node", "openclaw", "--container"])).toEqual({
ok: false,
error: "--container requires a value",
});
});
it("does not consume an adjacent flag as the container value", () => {
expect(
parseCliContainerArgs(["node", "openclaw", "--container", "--no-color", "status"]),
).toEqual({
ok: false,
error: "--container requires a value",
});
});
it("leaves argv unchanged when the flag is absent", () => {
expect(parseCliContainerArgs(["node", "openclaw", "status"])).toEqual({
ok: true,
container: null,
argv: ["node", "openclaw", "status"],
});
});
it("extracts --container after the command like other root options", () => {
expect(
parseCliContainerArgs(["node", "openclaw", "status", "--container", "demo", "--deep"]),
).toEqual({
ok: true,
container: "demo",
argv: ["node", "openclaw", "status", "--deep"],
});
});
it("stops parsing --container after the -- terminator", () => {
expect(
parseCliContainerArgs([
"node",
"openclaw",
"nodes",
"run",
"--",
"docker",
"run",
"--container",
"demo",
"alpine",
]),
).toEqual({
ok: true,
container: null,
argv: [
"node",
"openclaw",
"nodes",
"run",
"--",
"docker",
"run",
"--container",
"demo",
"alpine",
],
});
});
});
describe("resolveCliContainerTarget", () => {
it("uses argv first and falls back to OPENCLAW_CONTAINER", () => {
expect(
resolveCliContainerTarget(["node", "openclaw", "--container", "demo", "status"], {}),
).toBe("demo");
expect(resolveCliContainerTarget(["node", "openclaw", "status"], {})).toBeNull();
expect(
resolveCliContainerTarget(["node", "openclaw", "status"], {
OPENCLAW_CONTAINER: "demo",
} as NodeJS.ProcessEnv),
).toBe("demo");
});
});
describe("maybeRunCliInContainer", () => {
it("passes through when no container target is provided", () => {
expect(maybeRunCliInContainer(["node", "openclaw", "status"], { env: {} })).toEqual({
handled: false,
argv: ["node", "openclaw", "status"],
});
});
it("uses OPENCLAW_CONTAINER when the flag is absent", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: { OPENCLAW_CONTAINER: "demo" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
[
"exec",
"-i",
"--env",
"OPENCLAW_CONTAINER_HINT=demo",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"status",
],
{
stdio: "inherit",
env: {
OPENCLAW_CONTAINER: "",
},
},
);
});
it("clears inherited OPENCLAW_CONTAINER before execing into the child CLI", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: {
OPENCLAW_CONTAINER: "demo",
OPENCLAW_GATEWAY_TOKEN: "token",
} as NodeJS.ProcessEnv,
spawnSync,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
[
"exec",
"-i",
"--env",
"OPENCLAW_CONTAINER_HINT=demo",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"status",
],
{
stdio: "inherit",
env: {
OPENCLAW_CONTAINER: "",
OPENCLAW_GATEWAY_TOKEN: "token",
},
},
);
});
it("executes through podman when the named container is running", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
env: {},
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
1,
"podman",
["inspect", "--format", "{{.State.Running}}", "demo"],
{ encoding: "utf8" },
);
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
[
"exec",
"-i",
"--env",
"OPENCLAW_CONTAINER_HINT=demo",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"status",
],
{
stdio: "inherit",
env: { OPENCLAW_CONTAINER: "" },
},
);
});
it("falls back to docker when podman does not have the container", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "health"], {
env: { USER: "openclaw" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
2,
"docker",
["inspect", "--format", "{{.State.Running}}", "demo"],
{ encoding: "utf8" },
);
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"docker",
[
"exec",
"-i",
"-e",
"OPENCLAW_CONTAINER_HINT=demo",
"-e",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"health",
],
{
stdio: "inherit",
env: { USER: "openclaw", OPENCLAW_CONTAINER: "" },
},
);
});
it("falls back to sudo -u openclaw podman for the documented dedicated-user flow", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "openclaw", "status"], {
env: { USER: "somalley" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"sudo",
["-u", "openclaw", "podman", "inspect", "--format", "{{.State.Running}}", "openclaw"],
{ encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] },
);
expect(spawnSync).toHaveBeenNthCalledWith(
4,
"sudo",
[
"-u",
"openclaw",
"podman",
"exec",
"-i",
"--env",
"OPENCLAW_CONTAINER_HINT=openclaw",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"openclaw",
"openclaw",
"status",
],
{
stdio: "inherit",
env: { USER: "somalley", OPENCLAW_CONTAINER: "" },
},
);
});
it("checks docker before the dedicated-user podman fallback", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
env: { USER: "somalley" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
1,
"podman",
["inspect", "--format", "{{.State.Running}}", "demo"],
{ encoding: "utf8" },
);
expect(spawnSync).toHaveBeenNthCalledWith(
2,
"docker",
["inspect", "--format", "{{.State.Running}}", "demo"],
{ encoding: "utf8" },
);
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"docker",
[
"exec",
"-i",
"-e",
"OPENCLAW_CONTAINER_HINT=demo",
"-e",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"status",
],
{
stdio: "inherit",
env: { USER: "somalley", OPENCLAW_CONTAINER: "" },
},
);
expect(spawnSync).toHaveBeenCalledTimes(3);
});
it("rejects ambiguous matches across runtimes", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
env: { USER: "somalley" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toThrow(
'Container "demo" is running under multiple runtimes (podman, docker); use a unique container name.',
);
});
it("allocates a tty for interactive terminal sessions", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "setup"], {
env: {},
spawnSync,
stdinIsTTY: true,
stdoutIsTTY: true,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
[
"exec",
"-i",
"-t",
"--env",
"OPENCLAW_CONTAINER_HINT=demo",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"demo",
"openclaw",
"setup",
],
{
stdio: "inherit",
env: { OPENCLAW_CONTAINER: "" },
},
);
});
it("prefers --container over OPENCLAW_CONTAINER", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "flag-demo", "health"], {
env: { OPENCLAW_CONTAINER: "env-demo" } as NodeJS.ProcessEnv,
spawnSync,
}),
).toEqual({
handled: true,
exitCode: 0,
});
expect(spawnSync).toHaveBeenNthCalledWith(
1,
"podman",
["inspect", "--format", "{{.State.Running}}", "flag-demo"],
{ encoding: "utf8" },
);
});
it("throws when the named container is not running", () => {
const spawnSync = vi.fn().mockReturnValue({
status: 1,
stdout: "",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
env: {},
spawnSync,
}),
).toThrow('No running container matched "demo" under podman or docker.');
});
it("skips recursion when the bypass env is set", () => {
expect(
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
env: { OPENCLAW_CLI_CONTAINER_BYPASS: "1" } as NodeJS.ProcessEnv,
}),
).toEqual({
handled: false,
argv: ["node", "openclaw", "--container", "demo", "status"],
});
});
it("blocks updater commands from running inside the container", () => {
const spawnSync = vi.fn().mockReturnValue({
status: 0,
stdout: "true\n",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "update"], {
env: {},
spawnSync,
}),
).toThrow(
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
);
expect(spawnSync).not.toHaveBeenCalled();
});
it("blocks update after interleaved root flags", () => {
const spawnSync = vi.fn().mockReturnValue({
status: 0,
stdout: "true\n",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "--no-color", "update"], {
env: {},
spawnSync,
}),
).toThrow(
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
);
expect(spawnSync).not.toHaveBeenCalled();
});
it("blocks the --update shorthand from running inside the container", () => {
const spawnSync = vi.fn().mockReturnValue({
status: 0,
stdout: "true\n",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "--update"], {
env: {},
spawnSync,
}),
).toThrow(
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
);
expect(spawnSync).not.toHaveBeenCalled();
});
});

302
src/cli/container-target.ts Normal file
View File

@ -0,0 +1,302 @@
import { spawnSync } from "node:child_process";
import {
consumeRootOptionToken,
FLAG_TERMINATOR,
isValueToken,
} from "../infra/cli-root-options.js";
import { getPrimaryCommand } from "./argv.js";
type CliContainerParseResult =
| { ok: true; container: string | null; argv: string[] }
| { ok: false; error: string };
export type CliContainerTargetResult =
| { handled: true; exitCode: number }
| { handled: false; argv: string[] };
type ContainerTargetDeps = {
env: NodeJS.ProcessEnv;
spawnSync: typeof spawnSync;
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
};
type ContainerRuntimeExec = {
runtime: "podman" | "docker";
command: string;
argsPrefix: string[];
};
function takeValue(
raw: string,
next: string | undefined,
): {
value: string | null;
consumedNext: boolean;
} {
if (raw.includes("=")) {
const [, value] = raw.split("=", 2);
const trimmed = (value ?? "").trim();
return { value: trimmed || null, consumedNext: false };
}
const consumedNext = isValueToken(next);
const trimmed = consumedNext ? next!.trim() : "";
return { value: trimmed || null, consumedNext };
}
export function parseCliContainerArgs(argv: string[]): CliContainerParseResult {
if (argv.length < 2) {
return { ok: true, container: null, argv };
}
const out: string[] = argv.slice(0, 2);
let container: string | null = null;
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === undefined) {
continue;
}
if (arg === FLAG_TERMINATOR) {
out.push(arg, ...args.slice(i + 1));
break;
}
if (arg === "--container" || arg.startsWith("--container=")) {
const next = args[i + 1];
const { value, consumedNext } = takeValue(arg, next);
if (consumedNext) {
i += 1;
}
if (!value) {
return { ok: false, error: "--container requires a value" };
}
container = value;
continue;
}
const consumedRootOption = consumeRootOptionToken(args, i);
if (consumedRootOption > 0) {
for (let offset = 0; offset < consumedRootOption; offset += 1) {
const token = args[i + offset];
if (token !== undefined) {
out.push(token);
}
}
i += consumedRootOption - 1;
continue;
}
out.push(arg);
}
return { ok: true, container, argv: out };
}
export function resolveCliContainerTarget(
argv: string[],
env: NodeJS.ProcessEnv = process.env,
): string | null {
const parsed = parseCliContainerArgs(argv);
if (!parsed.ok) {
throw new Error(parsed.error);
}
return parsed.container ?? env.OPENCLAW_CONTAINER?.trim() ?? null;
}
function isContainerRunning(params: {
exec: ContainerRuntimeExec;
containerName: string;
deps: Pick<ContainerTargetDeps, "spawnSync">;
}): boolean {
const result = params.deps.spawnSync(
params.exec.command,
[...params.exec.argsPrefix, "inspect", "--format", "{{.State.Running}}", params.containerName],
params.exec.command === "sudo"
? { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] }
: { encoding: "utf8" },
);
return result.status === 0 && result.stdout.trim() === "true";
}
function candidateContainerRuntimes(env: NodeJS.ProcessEnv): ContainerRuntimeExec[] {
const candidates: ContainerRuntimeExec[] = [
{
runtime: "podman",
command: "podman",
argsPrefix: [],
},
{
runtime: "docker",
command: "docker",
argsPrefix: [],
},
];
const podmanUser = env.OPENCLAW_PODMAN_USER?.trim() || "openclaw";
const currentUser = env.USER?.trim() || env.LOGNAME?.trim() || "";
if (podmanUser && currentUser && podmanUser !== currentUser) {
candidates.push({
runtime: "podman",
command: "sudo",
argsPrefix: ["-u", podmanUser, "podman"],
});
}
return candidates;
}
function describeContainerRuntimeExec(exec: ContainerRuntimeExec): string {
if (exec.command === "sudo") {
const podmanUser = exec.argsPrefix[1];
return `podman (via sudo -u ${podmanUser})`;
}
return exec.runtime;
}
function resolveRunningContainer(params: {
containerName: string;
env: NodeJS.ProcessEnv;
deps: Pick<ContainerTargetDeps, "spawnSync">;
}): (ContainerRuntimeExec & { containerName: string }) | null {
const matches: Array<ContainerRuntimeExec & { containerName: string }> = [];
const candidates = candidateContainerRuntimes(params.env);
for (const exec of candidates) {
if (
isContainerRunning({
exec,
containerName: params.containerName,
deps: params.deps,
})
) {
matches.push({ ...exec, containerName: params.containerName });
if (exec.runtime === "docker") {
break;
}
}
}
if (matches.length === 0) {
return null;
}
if (matches.length > 1) {
const runtimes = matches.map(describeContainerRuntimeExec).join(", ");
throw new Error(
`Container "${params.containerName}" is running under multiple runtimes (${runtimes}); use a unique container name.`,
);
}
return matches[0];
}
function buildContainerExecArgs(params: {
exec: ContainerRuntimeExec;
containerName: string;
argv: string[];
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
}): string[] {
const envFlag = params.exec.runtime === "docker" ? "-e" : "--env";
const interactiveFlags = ["-i", ...(params.stdinIsTTY && params.stdoutIsTTY ? ["-t"] : [])];
return [
...params.exec.argsPrefix,
"exec",
...interactiveFlags,
envFlag,
`OPENCLAW_CONTAINER_HINT=${params.containerName}`,
envFlag,
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
params.containerName,
"openclaw",
...params.argv,
];
}
function buildContainerExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...env,
// The child CLI should render container-aware follow-up commands via
// OPENCLAW_CONTAINER_HINT, but it should not treat itself as still
// container-targeted for validation/routing.
OPENCLAW_CONTAINER: "",
};
}
function isBlockedContainerCommand(argv: string[]): boolean {
if (getPrimaryCommand(["node", "openclaw", ...argv]) === "update") {
return true;
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg || arg === FLAG_TERMINATOR) {
return false;
}
if (arg === "--update") {
return true;
}
const consumedRootOption = consumeRootOptionToken(argv, i);
if (consumedRootOption > 0) {
i += consumedRootOption - 1;
continue;
}
if (!arg.startsWith("-")) {
return false;
}
}
return false;
}
export function maybeRunCliInContainer(
argv: string[],
deps?: Partial<ContainerTargetDeps>,
): CliContainerTargetResult {
const resolvedDeps: ContainerTargetDeps = {
env: deps?.env ?? process.env,
spawnSync: deps?.spawnSync ?? spawnSync,
stdinIsTTY: deps?.stdinIsTTY ?? Boolean(process.stdin.isTTY),
stdoutIsTTY: deps?.stdoutIsTTY ?? Boolean(process.stdout.isTTY),
};
if (resolvedDeps.env.OPENCLAW_CLI_CONTAINER_BYPASS === "1") {
return { handled: false, argv };
}
const parsed = parseCliContainerArgs(argv);
if (!parsed.ok) {
throw new Error(parsed.error);
}
const containerName = resolveCliContainerTarget(argv, resolvedDeps.env);
if (!containerName) {
return { handled: false, argv: parsed.argv };
}
if (isBlockedContainerCommand(parsed.argv.slice(2))) {
throw new Error(
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
);
}
const runningContainer = resolveRunningContainer({
containerName,
env: resolvedDeps.env,
deps: resolvedDeps,
});
if (!runningContainer) {
throw new Error(`No running container matched "${containerName}" under podman or docker.`);
}
const result = resolvedDeps.spawnSync(
runningContainer.command,
buildContainerExecArgs({
exec: runningContainer,
containerName: runningContainer.containerName,
argv: parsed.argv.slice(2),
stdinIsTTY: resolvedDeps.stdinIsTTY,
stdoutIsTTY: resolvedDeps.stdoutIsTTY,
}),
{
stdio: "inherit",
env: buildContainerExecEnv(resolvedDeps.env),
},
);
return {
handled: true,
exitCode: typeof result.status === "number" ? result.status : 1,
};
}

View File

@ -67,6 +67,26 @@ describe("runServiceRestart token drift", () => {
stubEmptyGatewayEnv();
});
it("prints the container restart hint when restart is requested for a not-loaded service", async () => {
service.isLoaded.mockResolvedValue(false);
vi.stubEnv("OPENCLAW_CONTAINER_HINT", "openclaw-demo-container");
await runServiceRestart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [
"Restart the container or the service that manages it for openclaw-demo-container.",
"openclaw gateway install",
],
opts: { json: false },
});
expect(runtimeLogs).toContain("Gateway service not loaded.");
expect(runtimeLogs).toContain(
"Start with: Restart the container or the service that manages it for openclaw-demo-container.",
);
});
it("emits drift warning when enabled", async () => {
await runServiceRestart(createServiceRunArgs(true));

View File

@ -19,6 +19,7 @@ import {
type DaemonActionResponse,
emitDaemonActionJson,
} from "./response.js";
import { filterContainerGenericHints } from "./shared.js";
type DaemonLifecycleOptions = {
json?: boolean;
@ -81,7 +82,9 @@ async function handleServiceNotLoaded(params: {
json: boolean;
emit: ReturnType<typeof createActionIO>["emit"];
}) {
const hints = await maybeAugmentSystemdHints(params.renderStartHints());
const hints = filterContainerGenericHints(
await maybeAugmentSystemdHints(params.renderStartHints()),
);
params.emit({
ok: true,
result: "not-loaded",

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { theme } from "../../terminal/theme.js";
import { resolveRuntimeStatusColor } from "./shared.js";
import {
filterContainerGenericHints,
renderGatewayServiceStartHints,
resolveRuntimeStatusColor,
} from "./shared.js";
describe("resolveRuntimeStatusColor", () => {
it("maps known runtime states to expected theme colors", () => {
@ -14,3 +18,55 @@ describe("resolveRuntimeStatusColor", () => {
expect(resolveRuntimeStatusColor(undefined)).toBe(theme.muted);
});
});
describe("renderGatewayServiceStartHints", () => {
it("prepends a single container restart hint when OPENCLAW_CONTAINER is set", () => {
expect(
renderGatewayServiceStartHints({
OPENCLAW_CONTAINER: "openclaw-demo-container",
} as NodeJS.ProcessEnv),
).toEqual(
expect.arrayContaining([
"Restart the container or the service that manages it for openclaw-demo-container.",
]),
);
});
it("prepends a single container restart hint when OPENCLAW_CONTAINER_HINT is set", () => {
expect(
renderGatewayServiceStartHints({
OPENCLAW_CONTAINER_HINT: "openclaw-demo-container",
} as NodeJS.ProcessEnv),
).toEqual(
expect.arrayContaining([
"Restart the container or the service that manages it for openclaw-demo-container.",
]),
);
});
});
describe("filterContainerGenericHints", () => {
it("drops the generic container foreground hint when OPENCLAW_CONTAINER is set", () => {
expect(
filterContainerGenericHints(
[
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
],
{ OPENCLAW_CONTAINER: "openclaw-demo-container" } as NodeJS.ProcessEnv,
),
).toEqual([]);
});
it("drops the generic container foreground hint when OPENCLAW_CONTAINER_HINT is set", () => {
expect(
filterContainerGenericHints(
[
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
],
{ OPENCLAW_CONTAINER_HINT: "openclaw-demo-container" } as NodeJS.ProcessEnv,
),
).toEqual([]);
});
});

View File

@ -181,11 +181,30 @@ export function renderRuntimeHints(
export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] {
const profile = env.OPENCLAW_PROFILE;
return buildPlatformServiceStartHints({
const container = env.OPENCLAW_CONTAINER_HINT?.trim() || env.OPENCLAW_CONTAINER?.trim();
const hints = buildPlatformServiceStartHints({
installCommand: formatCliCommand("openclaw gateway install", env),
startCommand: formatCliCommand("openclaw gateway", env),
launchAgentPlistPath: `~/Library/LaunchAgents/${resolveGatewayLaunchAgentLabel(profile)}.plist`,
systemdServiceName: resolveGatewaySystemdServiceName(profile),
windowsTaskName: resolveGatewayWindowsTaskName(profile),
});
if (!container) {
return hints;
}
return [`Restart the container or the service that manages it for ${container}.`];
}
export function filterContainerGenericHints(
hints: string[],
env: NodeJS.ProcessEnv = process.env,
): string[] {
if (!(env.OPENCLAW_CONTAINER_HINT?.trim() || env.OPENCLAW_CONTAINER?.trim())) {
return hints;
}
return hints.filter(
(hint) =>
!hint.includes("If you're in a container, run the gateway in the foreground instead of") &&
!hint.includes("systemd user services are unavailable; install/enable systemd"),
);
}

View File

@ -32,6 +32,11 @@ vi.mock("../infra/device-pairing.js", () => ({
vi.mock("../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../runtime.js")>()),
defaultRuntime: runtime,
writeRuntimeJson: (
targetRuntime: { log: (...args: unknown[]) => void },
value: unknown,
space = 2,
) => targetRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
}));
let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli;

View File

@ -59,7 +59,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
onlyPluginIds: ["telegram"],
throwOnLoadError: true,
}),
);
@ -86,7 +86,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ onlyPluginIds: [], throwOnLoadError: true }),
expect.objectContaining({ onlyPluginIds: ["telegram"], throwOnLoadError: true }),
);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
2,

View File

@ -19,6 +19,29 @@ describe("parseCliProfileArgs", () => {
expect(res.argv).toEqual(["node", "openclaw", "gateway", "--dev", "--allow-unconfigured"]);
});
it("leaves gateway --dev for subcommands after leading root options", () => {
const res = parseCliProfileArgs([
"node",
"openclaw",
"--no-color",
"gateway",
"--dev",
"--allow-unconfigured",
]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBeNull();
expect(res.argv).toEqual([
"node",
"openclaw",
"--no-color",
"gateway",
"--dev",
"--allow-unconfigured",
]);
});
it("still accepts global --dev before subcommand", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--dev", "gateway"]);
if (!res.ok) {
@ -37,6 +60,24 @@ describe("parseCliProfileArgs", () => {
expect(res.argv).toEqual(["node", "openclaw", "status"]);
});
it("parses interleaved --profile after the command token", () => {
const res = parseCliProfileArgs(["node", "openclaw", "status", "--profile", "work", "--deep"]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("work");
expect(res.argv).toEqual(["node", "openclaw", "status", "--deep"]);
});
it("parses interleaved --dev after the command token", () => {
const res = parseCliProfileArgs(["node", "openclaw", "status", "--dev"]);
if (!res.ok) {
throw new Error(res.error);
}
expect(res.profile).toBe("dev");
expect(res.argv).toEqual(["node", "openclaw", "status"]);
});
it("rejects missing profile value", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--profile"]);
expect(res.ok).toBe(false);
@ -45,6 +86,7 @@ describe("parseCliProfileArgs", () => {
it.each([
["--dev first", ["node", "openclaw", "--dev", "--profile", "work", "status"]],
["--profile first", ["node", "openclaw", "--profile", "work", "--dev", "status"]],
["interleaved after command", ["node", "openclaw", "status", "--profile", "work", "--dev"]],
])("rejects combining --dev with --profile (%s)", (_name, argv) => {
const res = parseCliProfileArgs(argv);
expect(res.ok).toBe(false);
@ -165,4 +207,28 @@ describe("formatCliCommand", () => {
"pnpm openclaw --profile work doctor",
);
});
it("inserts --container when a container hint is set", () => {
expect(
formatCliCommand("openclaw gateway status --deep", { OPENCLAW_CONTAINER_HINT: "demo" }),
).toBe("openclaw --container demo gateway status --deep");
});
it("preserves both --container and --profile hints", () => {
expect(
formatCliCommand("openclaw doctor", {
OPENCLAW_CONTAINER_HINT: "demo",
OPENCLAW_PROFILE: "work",
}),
).toBe("openclaw --container demo doctor");
});
it("does not prepend --container for update commands", () => {
expect(formatCliCommand("openclaw update", { OPENCLAW_CONTAINER_HINT: "demo" })).toBe(
"openclaw update",
);
expect(
formatCliCommand("pnpm openclaw update --channel beta", { OPENCLAW_CONTAINER_HINT: "demo" }),
).toBe("pnpm openclaw update --channel beta");
});
});

View File

@ -1,6 +1,12 @@
import os from "node:os";
import path from "node:path";
import {
consumeRootOptionToken,
FLAG_TERMINATOR,
isValueToken,
} from "../infra/cli-root-options.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { getPrimaryCommand } from "./argv.js";
import { isValidProfileName } from "./profile-utils.js";
export type CliProfileParseResult =
@ -19,8 +25,9 @@ function takeValue(
const trimmed = (value ?? "").trim();
return { value: trimmed || null, consumedNext: false };
}
const trimmed = (next ?? "").trim();
return { value: trimmed || null, consumedNext: Boolean(next) };
const consumedNext = isValueToken(next);
const trimmed = consumedNext ? next!.trim() : "";
return { value: trimmed || null, consumedNext };
}
export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
@ -31,7 +38,6 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
const out: string[] = argv.slice(0, 2);
let profile: string | null = null;
let sawDev = false;
let sawCommand = false;
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
@ -39,13 +45,16 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
if (arg === undefined) {
continue;
}
if (sawCommand) {
out.push(arg);
continue;
if (arg === FLAG_TERMINATOR) {
out.push(arg, ...args.slice(i + 1));
break;
}
if (arg === "--dev") {
if (getPrimaryCommand(out) === "gateway") {
out.push(arg);
continue;
}
if (profile && profile !== "dev") {
return { ok: false, error: "Cannot combine --dev with --profile" };
}
@ -76,9 +85,15 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
continue;
}
if (!arg.startsWith("-")) {
sawCommand = true;
out.push(arg);
const consumedRootOption = consumeRootOptionToken(args, i);
if (consumedRootOption > 0) {
for (let offset = 0; offset < consumedRootOption; offset += 1) {
const token = args[i + offset];
if (token !== undefined) {
out.push(token);
}
}
i += consumedRootOption - 1;
continue;
}

View File

@ -49,6 +49,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
.name(CLI_NAME)
.description("")
.version(ctx.programVersion)
.option(
"--container <name>",
"Run the CLI inside a running Podman/Docker container named <name> (default: env OPENCLAW_CONTAINER)",
)
.option(
"--dev",
"Dev profile: isolate state under ~/.openclaw-dev, default gateway port 19001, and shift derived ports (browser/canvas)",

View File

@ -9,11 +9,21 @@ const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const buildProgramMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
>((argv: string[]) => ({ handled: false, argv })),
);
vi.mock("./route.js", () => ({
tryRouteCli: tryRouteCliMock,
}));
vi.mock("./container-target.js", () => ({
maybeRunCliInContainer: maybeRunCliInContainerMock,
parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }),
}));
vi.mock("./dotenv.js", () => ({
loadCliDotEnv: loadDotEnvMock,
}));
@ -57,6 +67,7 @@ describe("runCli exit behavior", () => {
await runCli(["node", "openclaw", "status"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
@ -70,6 +81,7 @@ describe("runCli exit behavior", () => {
await runCli(["node", "openclaw", "--help"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
@ -77,4 +89,31 @@ describe("runCli exit behavior", () => {
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it("returns after a handled container-target invocation", async () => {
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 0 });
await runCli(["node", "openclaw", "--container", "demo", "status"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith([
"node",
"openclaw",
"--container",
"demo",
"status",
]);
expect(loadDotEnvMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(closeAllMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("propagates a handled container-target exit code", async () => {
const exitCode = process.exitCode;
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 7 });
await runCli(["node", "openclaw", "--container", "demo", "status"]);
expect(process.exitCode).toBe(7);
process.exitCode = exitCode;
});
});

View File

@ -3,15 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const dotenvState = vi.hoisted(() => {
const state = {
profileAtDotenvLoad: undefined as string | undefined,
containerAtDotenvLoad: undefined as string | undefined,
};
return {
state,
loadDotEnv: vi.fn(() => {
state.profileAtDotenvLoad = process.env.OPENCLAW_PROFILE;
state.containerAtDotenvLoad = process.env.OPENCLAW_CONTAINER;
}),
};
});
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn((argv: string[]) => ({ handled: false, argv })),
);
vi.mock("./dotenv.js", () => ({
loadCliDotEnv: dotenvState.loadDotEnv,
}));
@ -36,19 +42,39 @@ vi.mock("./windows-argv.js", () => ({
normalizeWindowsArgv: (argv: string[]) => argv,
}));
vi.mock("./container-target.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./container-target.js")>();
return {
...actual,
maybeRunCliInContainer: maybeRunCliInContainerMock,
};
});
import { runCli } from "./run-main.js";
describe("runCli profile env bootstrap", () => {
const originalProfile = process.env.OPENCLAW_PROFILE;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const originalContainer = process.env.OPENCLAW_CONTAINER;
const originalGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
const originalGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
const originalGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
beforeEach(() => {
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_CONFIG_PATH;
delete process.env.OPENCLAW_CONTAINER;
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_GATEWAY_URL;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
dotenvState.state.profileAtDotenvLoad = undefined;
dotenvState.state.containerAtDotenvLoad = undefined;
dotenvState.loadDotEnv.mockClear();
maybeRunCliInContainerMock.mockClear();
});
afterEach(() => {
@ -57,6 +83,11 @@ describe("runCli profile env bootstrap", () => {
} else {
process.env.OPENCLAW_PROFILE = originalProfile;
}
if (originalContainer === undefined) {
delete process.env.OPENCLAW_CONTAINER;
} else {
process.env.OPENCLAW_CONTAINER = originalContainer;
}
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
@ -67,6 +98,26 @@ describe("runCli profile env bootstrap", () => {
} else {
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
}
if (originalGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = originalGatewayPort;
}
if (originalGatewayUrl === undefined) {
delete process.env.OPENCLAW_GATEWAY_URL;
} else {
process.env.OPENCLAW_GATEWAY_URL = originalGatewayUrl;
}
if (originalGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
}
if (originalGatewayPassword === undefined) {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = originalGatewayPassword;
}
});
it("applies --profile before dotenv loading", async () => {
@ -76,4 +127,87 @@ describe("runCli profile env bootstrap", () => {
expect(dotenvState.state.profileAtDotenvLoad).toBe("rawdog");
expect(process.env.OPENCLAW_PROFILE).toBe("rawdog");
});
it("rejects --container combined with --profile", async () => {
await expect(
runCli(["node", "openclaw", "--container", "demo", "--profile", "rawdog", "status"]),
).rejects.toThrow(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
expect(dotenvState.loadDotEnv).not.toHaveBeenCalled();
expect(process.env.OPENCLAW_PROFILE).toBe("rawdog");
});
it("rejects --container combined with interleaved --profile", async () => {
await expect(
runCli(["node", "openclaw", "status", "--container", "demo", "--profile", "rawdog"]),
).rejects.toThrow(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
});
it("rejects --container combined with interleaved --dev", async () => {
await expect(
runCli(["node", "openclaw", "status", "--container", "demo", "--dev"]),
).rejects.toThrow(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
});
it("does not let dotenv change container target resolution", async () => {
dotenvState.loadDotEnv.mockImplementationOnce(() => {
process.env.OPENCLAW_CONTAINER = "demo";
dotenvState.state.profileAtDotenvLoad = process.env.OPENCLAW_PROFILE;
dotenvState.state.containerAtDotenvLoad = process.env.OPENCLAW_CONTAINER;
});
await runCli(["node", "openclaw", "status"]);
expect(dotenvState.loadDotEnv).toHaveBeenCalledOnce();
expect(process.env.OPENCLAW_CONTAINER).toBe("demo");
expect(dotenvState.state.containerAtDotenvLoad).toBe("demo");
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(maybeRunCliInContainerMock).toHaveReturnedWith({
handled: false,
argv: ["node", "openclaw", "status"],
});
});
it("rejects container mode when OPENCLAW_PROFILE is already set in env", async () => {
process.env.OPENCLAW_PROFILE = "work";
await expect(runCli(["node", "openclaw", "--container", "demo", "status"])).rejects.toThrow(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
});
it.each([
["OPENCLAW_GATEWAY_PORT", "19001"],
["OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789"],
["OPENCLAW_GATEWAY_TOKEN", "demo-token"],
["OPENCLAW_GATEWAY_PASSWORD", "demo-password"],
])("rejects container mode when %s is set in env", async (key, value) => {
process.env[key] = value;
await expect(runCli(["node", "openclaw", "--container", "demo", "status"])).rejects.toThrow(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
});
it("allows container mode when only OPENCLAW_STATE_DIR is set in env", async () => {
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-host-state";
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),
).resolves.toBeUndefined();
});
it("allows container mode when only OPENCLAW_CONFIG_PATH is set in env", async () => {
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-host-state/openclaw.json";
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),
).resolves.toBeUndefined();
});
});

View File

@ -12,6 +12,7 @@ import {
hasHelpOrVersion,
isRootHelpInvocation,
} from "./argv.js";
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
import { loadCliDotEnv } from "./dotenv.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { tryRouteCli } from "./route.js";
@ -80,15 +81,42 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean {
}
export async function runCli(argv: string[] = process.argv) {
let normalizedArgv = normalizeWindowsArgv(argv);
const parsedProfile = parseCliProfileArgs(normalizedArgv);
const originalArgv = normalizeWindowsArgv(argv);
const parsedContainer = parseCliContainerArgs(originalArgv);
if (!parsedContainer.ok) {
throw new Error(parsedContainer.error);
}
const parsedProfile = parseCliProfileArgs(parsedContainer.argv);
if (!parsedProfile.ok) {
throw new Error(parsedProfile.error);
}
if (parsedProfile.profile) {
applyCliProfileEnv({ profile: parsedProfile.profile });
}
normalizedArgv = parsedProfile.argv;
const containerTargetName =
parsedContainer.container ?? process.env.OPENCLAW_CONTAINER?.trim() ?? null;
if (
containerTargetName &&
(parsedProfile.profile ||
process.env.OPENCLAW_PROFILE?.trim() ||
process.env.OPENCLAW_GATEWAY_PORT?.trim() ||
process.env.OPENCLAW_GATEWAY_URL?.trim() ||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim())
) {
throw new Error(
"--container cannot be combined with --profile/--dev or gateway override env vars",
);
}
const containerTarget = maybeRunCliInContainer(originalArgv);
if (containerTarget.handled) {
if (containerTarget.exitCode !== 0) {
process.exitCode = containerTarget.exitCode;
}
return;
}
let normalizedArgv = parsedProfile.argv;
loadCliDotEnv({ quiet: true });
normalizeEnv();

View File

@ -16,6 +16,8 @@ vi.mock("./gateway-rpc.js", () => ({
vi.mock("../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../runtime.js")>()),
defaultRuntime,
writeRuntimeJson: (runtime: { log: (...args: unknown[]) => void }, value: unknown, space = 2) =>
runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
}));
const { registerSystemCli } = await import("./system-cli.js");

View File

@ -438,10 +438,11 @@ describe("update-cli", () => {
name: "json output",
options: { json: true },
assert: () => {
const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0];
expect(typeof last).toBe("string");
const parsed = JSON.parse(String(last));
expect(parsed.channel.value).toBe("stable");
const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(last).toBeDefined();
const parsed = last as Record<string, unknown>;
const channel = parsed.channel as { value?: unknown };
expect(channel.value).toBe("stable");
},
},
] as const;
@ -727,19 +728,11 @@ describe("update-cli", () => {
name: "outputs JSON when --json is set",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.log).mockClear();
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true });
},
assert: () => {
const logCalls = vi.mocked(defaultRuntime.log).mock.calls;
const jsonOutput = logCalls.find((call) => {
try {
JSON.parse(call[0] as string);
return true;
} catch {
return false;
}
});
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(jsonOutput).toBeDefined();
},
},

View File

@ -7,6 +7,7 @@ describe("entry root help fast path", () => {
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], {
outputRootHelp: outputRootHelpMock,
env: {},
});
expect(handled).toBe(true);
@ -18,9 +19,25 @@ describe("entry root help fast path", () => {
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], {
outputRootHelp: outputRootHelpMock,
env: {},
});
expect(handled).toBe(false);
expect(outputRootHelpMock).not.toHaveBeenCalled();
});
it("skips the host help fast path when a container target is active", () => {
const outputRootHelpMock = vi.fn();
const handled = tryHandleRootHelpFastPath(
["node", "openclaw", "--container", "demo", "--help"],
{
outputRootHelp: outputRootHelpMock,
env: {},
},
);
expect(handled).toBe(false);
expect(outputRootHelpMock).not.toHaveBeenCalled();
});
});

View File

@ -4,6 +4,7 @@ import { enableCompileCache } from "node:module";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js";
import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
import { buildCliRespawnPlan } from "./entry.respawn.js";
@ -99,6 +100,9 @@ if (
}
function tryHandleRootVersionFastPath(argv: string[]): boolean {
if (resolveCliContainerTarget(argv)) {
return false;
}
if (!isRootVersionInvocation(argv)) {
return false;
}
@ -121,13 +125,35 @@ if (
process.argv = normalizeWindowsArgv(process.argv);
if (!ensureCliRespawnReady()) {
const parsed = parseCliProfileArgs(process.argv);
const parsedContainer = parseCliContainerArgs(process.argv);
if (!parsedContainer.ok) {
console.error(`[openclaw] ${parsedContainer.error}`);
process.exit(2);
}
const parsed = parseCliProfileArgs(parsedContainer.argv);
if (!parsed.ok) {
// Keep it simple; Commander will handle rich help/errors after we strip flags.
console.error(`[openclaw] ${parsed.error}`);
process.exit(2);
}
const containerTargetName = resolveCliContainerTarget(process.argv);
if (
containerTargetName &&
(parsed.profile ||
process.env.OPENCLAW_PROFILE?.trim() ||
process.env.OPENCLAW_GATEWAY_PORT?.trim() ||
process.env.OPENCLAW_GATEWAY_URL?.trim() ||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim())
) {
console.error(
"[openclaw] --container cannot be combined with --profile/--dev or gateway override env vars",
);
process.exit(2);
}
if (parsed.profile) {
applyCliProfileEnv({ profile: parsed.profile });
// Keep Commander and ad-hoc argv checks consistent.
@ -145,8 +171,12 @@ export function tryHandleRootHelpFastPath(
deps: {
outputRootHelp?: () => void;
onError?: (error: unknown) => void;
env?: NodeJS.ProcessEnv;
} = {},
): boolean {
if (resolveCliContainerTarget(argv, deps.env)) {
return false;
}
if (!isRootHelpInvocation(argv)) {
return false;
}

View File

@ -10,7 +10,9 @@ const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true));
const normalizeEnvMock = vi.hoisted(() => vi.fn());
const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv));
const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv })));
const resolveCliContainerTargetMock = vi.hoisted(() => vi.fn<() => string | null>(() => null));
const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234"));
const runCliMock = vi.hoisted(() => vi.fn(async () => {}));
const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("./cli/argv.js", () => ({
@ -18,11 +20,20 @@ vi.mock("./cli/argv.js", () => ({
isRootVersionInvocation: isRootVersionInvocationMock,
}));
vi.mock("./cli/container-target.js", () => ({
parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }),
resolveCliContainerTarget: resolveCliContainerTargetMock,
}));
vi.mock("./cli/profile.js", () => ({
applyCliProfileEnv: applyCliProfileEnvMock,
parseCliProfileArgs: parseCliProfileArgsMock,
}));
vi.mock("./cli/run-main.js", () => ({
runCli: runCliMock,
}));
vi.mock("./cli/respawn-policy.js", () => ({
shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
}));
@ -58,12 +69,15 @@ vi.mock("./version.js", () => ({
describe("entry root version fast path", () => {
let originalArgv: string[];
let originalGatewayToken: string | undefined;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
originalArgv = [...process.argv];
originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
process.argv = ["node", "openclaw", "--version"];
exitSpy = vi
.spyOn(process, "exit")
@ -72,6 +86,11 @@ describe("entry root version fast path", () => {
afterEach(() => {
process.argv = originalArgv;
if (originalGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
}
exitSpy.mockRestore();
});
@ -101,4 +120,37 @@ describe("entry root version fast path", () => {
logSpy.mockRestore();
});
it("skips the host version fast path when a container target is active", async () => {
resolveCliContainerTargetMock.mockReturnValue("demo");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await import("./entry.js");
await vi.waitFor(() => {
expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]);
});
expect(logSpy).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
logSpy.mockRestore();
});
it("rejects container mode for root version when gateway override env vars are set", async () => {
resolveCliContainerTargetMock.mockReturnValue("demo");
process.env.OPENCLAW_GATEWAY_TOKEN = "demo-token";
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await import("./entry.js");
await vi.waitFor(() => {
expect(errorSpy).toHaveBeenCalledWith(
"[openclaw] --container cannot be combined with --profile/--dev or gateway override env vars",
);
expect(exitSpy).toHaveBeenCalledWith(2);
});
expect(runCliMock).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
});

View File

@ -22,7 +22,9 @@ describe("consumeRootOptionToken", () => {
{ args: ["--dev"], index: 0, expected: 1 },
{ args: ["--profile=work"], index: 0, expected: 1 },
{ args: ["--log-level=debug"], index: 0, expected: 1 },
{ args: ["--container=openclaw-demo"], index: 0, expected: 1 },
{ args: ["--profile", "work"], index: 0, expected: 2 },
{ args: ["--container", "openclaw-demo"], index: 0, expected: 2 },
{ args: ["--profile", "-1"], index: 0, expected: 2 },
{ args: ["--log-level", "-1.5"], index: 0, expected: 2 },
{ args: ["--profile", "--no-color"], index: 0, expected: 1 },

View File

@ -1,7 +1,7 @@
export const FLAG_TERMINATOR = "--";
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]);
export function isValueToken(arg: string | undefined): boolean {
if (!arg || arg === FLAG_TERMINATOR) {
@ -21,7 +21,11 @@ export function consumeRootOptionToken(args: ReadonlyArray<string>, index: numbe
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
return 1;
}
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
if (
arg.startsWith("--profile=") ||
arg.startsWith("--log-level=") ||
arg.startsWith("--container=")
) {
return 1;
}
if (ROOT_VALUE_FLAGS.has(arg)) {