mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:41:40 +00:00
624 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|