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 host routing and gateway env 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_PROFILE: "work", OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", OPENCLAW_GATEWAY_TOKEN: "token", OPENCLAW_GATEWAY_PASSWORD: "password", } 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: "", }, }, ); }); 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("checks docker after podman and before failing", () => { 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("does not try any sudo podman fallback for regular users", () => { const spawnSync = vi .fn() .mockReturnValueOnce({ status: 1, stdout: "", }) .mockReturnValueOnce({ status: 1, stdout: "", }); expect(() => maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], { env: { USER: "somalley" } as NodeJS.ProcessEnv, spawnSync, }), ).toThrow('No running container matched "demo" under podman or docker.'); expect(spawnSync).toHaveBeenCalledTimes(2); expect(spawnSync).toHaveBeenNthCalledWith( 1, "podman", ["inspect", "--format", "{{.State.Running}}", "demo"], { encoding: "utf8" }, ); expect(spawnSync).toHaveBeenNthCalledWith( 2, "docker", ["inspect", "--format", "{{.State.Running}}", "demo"], { encoding: "utf8" }, ); }); 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(); }); });