diff --git a/CHANGELOG.md b/CHANGELOG.md index 5793b87ceaa..efc396ee7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index afecb06f6fd..db4f4d39d26 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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"]); diff --git a/src/cli/command-format.ts b/src/cli/command-format.ts index cc9477b5aa6..b2b856d43fa 100644 --- a/src/cli/command-format.ts +++ b/src/cli/command-format.ts @@ -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(" ")}`); } diff --git a/src/cli/container-target.test.ts b/src/cli/container-target.test.ts new file mode 100644 index 00000000000..39de5cc4f66 --- /dev/null +++ b/src/cli/container-target.test.ts @@ -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(); + }); +}); diff --git a/src/cli/container-target.ts b/src/cli/container-target.ts new file mode 100644 index 00000000000..72fb4797590 --- /dev/null +++ b/src/cli/container-target.ts @@ -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; +}): 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; +}): (ContainerRuntimeExec & { containerName: string }) | null { + const matches: Array = []; + 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, +): 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, + }; +} diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 2f17269eb6c..0fd4fd81f82 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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)); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 8def6aeefe6..9028fd58604 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -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["emit"]; }) { - const hints = await maybeAugmentSystemdHints(params.renderStartHints()); + const hints = filterContainerGenericHints( + await maybeAugmentSystemdHints(params.renderStartHints()), + ); params.emit({ ok: true, result: "not-loaded", diff --git a/src/cli/daemon-cli/shared.test.ts b/src/cli/daemon-cli/shared.test.ts index 37d6147ec5c..2784d7c0d93 100644 --- a/src/cli/daemon-cli/shared.test.ts +++ b/src/cli/daemon-cli/shared.test.ts @@ -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([]); + }); +}); diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index eb2760c2630..37c3e5fc71f 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -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"), + ); } diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index c32e06cd07b..bf6575b17e3 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -32,6 +32,11 @@ vi.mock("../infra/device-pairing.js", () => ({ vi.mock("../runtime.js", async (importOriginal) => ({ ...(await importOriginal()), 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; diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index 30714b8a023..5d0b9342fa5 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -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, diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 3351df22dd4..e2955d28412 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -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"); + }); }); diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 8948ab43f6a..02b6f8ace6a 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -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; } diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index fc924cec9d3..6513b177960 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -49,6 +49,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { .name(CLI_NAME) .description("") .version(ctx.programVersion) + .option( + "--container ", + "Run the CLI inside a running Podman/Docker container named (default: env OPENCLAW_CONTAINER)", + ) .option( "--dev", "Dev profile: isolate state under ~/.openclaw-dev, default gateway port 19001, and shift derived ports (browser/canvas)", diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index aeed204f739..e9ee0b50f48 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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; + }); }); diff --git a/src/cli/run-main.profile-env.test.ts b/src/cli/run-main.profile-env.test.ts index fc0d9bddae6..b1f0134a73d 100644 --- a/src/cli/run-main.profile-env.test.ts +++ b/src/cli/run-main.profile-env.test.ts @@ -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(); + 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(); + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 594b99ae0b3..ccd09900997 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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(); diff --git a/src/cli/system-cli.test.ts b/src/cli/system-cli.test.ts index 7d86b503440..24cb4c16707 100644 --- a/src/cli/system-cli.test.ts +++ b/src/cli/system-cli.test.ts @@ -16,6 +16,8 @@ vi.mock("./gateway-rpc.js", () => ({ vi.mock("../runtime.js", async (importOriginal) => ({ ...(await importOriginal()), 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"); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d254f5f1ed1..a3079e36f2e 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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; + 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(); }, }, diff --git a/src/entry.test.ts b/src/entry.test.ts index 8d444d5c205..820d537f209 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -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(); + }); }); diff --git a/src/entry.ts b/src/entry.ts index 451cefd5d41..a7673ba1b01 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -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; } diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts index a7aa0bad672..ef1b6fbba40 100644 --- a/src/entry.version-fast-path.test.ts +++ b/src/entry.version-fast-path.test.ts @@ -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; 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(); + }); }); diff --git a/src/infra/cli-root-options.test.ts b/src/infra/cli-root-options.test.ts index 6d7461a39e5..b9987bb9ce9 100644 --- a/src/infra/cli-root-options.test.ts +++ b/src/infra/cli-root-options.test.ts @@ -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 }, diff --git a/src/infra/cli-root-options.ts b/src/infra/cli-root-options.ts index 9522e114966..f59efefb278 100644 --- a/src/infra/cli-root-options.ts +++ b/src/infra/cli-root-options.ts @@ -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, 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)) {