Files
openclaw/src/cli/container-target.ts
2026-03-24 21:00:36 +00:00

283 lines
7.8 KiB
TypeScript

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