Files
openclaw/src/cli/container-target.test.ts
2026-03-24 10:17:17 -04:00

624 lines
14 KiB
TypeScript

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();
});
});