Files
OmniRoute/src/shared/utils/containerEnv.ts
diegosouzapw 1249f714fe fix(cli): do not treat a tmpfs mount as proof a config path reaches the host
hasBindMountAt() accepted ANY mount as evidence that a would-be CLI config
write reaches the operator's host: it matched on the mount point alone and
never looked at the filesystem type. An in-memory mount therefore cleared the
ephemeral flag, so guardCliConfigWrite() let the write through and both
POST /api/cli-tools/apply and the dashboard's guide-settings writer answered
200 instead of the safe 422 that #10057 added.

That is the exact case the guard exists to refuse, and the worst one: a
container running with `--tmpfs /tmp` (or a home on tmpfs) loses the file even
before the container is recreated, while the UI reports success.

Parse the filesystem type from mountinfo (the field after the lone "-"
separator) and skip mounts backed by RAM or kernel state. Real bind mounts
(ext4/xfs/nfs/virtiofs/fuse.*) still count, including one nested under a tmpfs
path, so the compose `host` profile is unaffected. A line carrying no
separator proves nothing and is skipped too.

Regression cover added to tests/unit/container-env-detect.test.ts; this also
un-reds tests/unit/cli-tools-apply-container-422.test.ts and
tests/unit/api/cli-tools/apply-container-guard.test.ts, which were failing on
any box whose /tmp is a tmpfs.
2026-08-25 08:12:51 +00:00

199 lines
6.5 KiB
TypeScript

import fs from "node:fs";
/**
* Container awareness for CLI-tool config writes.
*
* OmniRoute frequently runs as a container while the AI CLIs it configures
* (Codex, Claude Code, Cursor, ...) live on the operator's host. Writing
* `~/.codex/...` inside the container "succeeds" and then silently disappears
* with the container, so every auto-config write path consults this module
* before touching disk.
*
* A bind mount is treated as the operator's explicit statement that a path
* reaches the host, which is what makes the compose `host` profile safe.
*/
export interface ContainerEnvDeps {
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: string) => string;
env: NodeJS.ProcessEnv;
}
const defaultDeps = (): ContainerEnvDeps => ({
existsSync: fs.existsSync,
readFileSync: (path, encoding) => fs.readFileSync(path, encoding as BufferEncoding) as string,
env: process.env,
});
/** cgroup substrings emitted by the common container runtimes. */
const CGROUP_MARKERS = ["docker", "containerd", "kubepods", "podman", "lxc"];
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
/**
* Best-effort container detection. Superset of the Zed-specific
* `isRunningInDocker()` (kept separate so its callers keep their behavior).
*
* `OMNIROUTE_CONTAINER` forces the answer either way — needed for tests and for
* operators on exotic runtimes we fail to recognise.
*/
export function isRunningInContainer(deps: ContainerEnvDeps = defaultDeps()): boolean {
const override = String(deps.env?.OMNIROUTE_CONTAINER ?? "")
.trim()
.toLowerCase();
if (TRUE_VALUES.has(override)) return true;
if (FALSE_VALUES.has(override)) return false;
for (const marker of ["/.dockerenv", "/run/.containerenv"]) {
try {
if (deps.existsSync(marker)) return true;
} catch {
// not Linux, or permission denied — fall through to the next probe
}
}
if (deps.env?.KUBERNETES_SERVICE_HOST) return true;
try {
const cgroup = deps.readFileSync("/proc/1/cgroup", "utf8");
if (CGROUP_MARKERS.some((marker) => cgroup.includes(marker))) return true;
} catch {
// /proc not mounted
}
return false;
}
/** mountinfo escapes these four characters as octal sequences. */
function decodeMountPath(raw: string): string {
return raw
.replace(/\\040/g, " ")
.replace(/\\011/g, "\t")
.replace(/\\012/g, "\n")
.replace(/\\134/g, "\\");
}
/** Strip a trailing slash so "/host-home/" and "/host-home" compare equal. */
function stripTrailingSlash(p: string): string {
return p.length > 1 && p.endsWith("/") ? p.replace(/\/+$/, "") : p;
}
/**
* Filesystems that live in RAM or expose kernel state. A mount of one of these
* is never a bind mount from the host: a path under `--tmpfs /tmp`, or a
* container whose home sits on tmpfs, loses the file even before the container
* is recreated -- exactly the throwaway write this module exists to refuse.
* Every other type (ext4, xfs, btrfs, zfs, nfs, virtiofs, fuse.*, ...) can
* carry host data, so it still counts as proof the operator wired the path in.
*/
const NON_HOST_FS_TYPES = new Set([
"autofs",
"binfmt_misc",
"bpf",
"cgroup",
"cgroup2",
"configfs",
"debugfs",
"devpts",
"devtmpfs",
"efivarfs",
"fusectl",
"hugetlbfs",
"mqueue",
"nsfs",
"proc",
"pstore",
"ramfs",
"rpc_pipefs",
"securityfs",
"selinuxfs",
"sysfs",
"tmpfs",
"tracefs",
]);
/**
* mountinfo puts a variable number of optional fields after field 7 and closes
* them with a lone "-"; the field right after that separator is the filesystem
* type. Returns null when the line carries no separator, which the caller
* treats as "not proof of a host mount".
*/
function mountFsType(fields: string[]): string | null {
const separator = fields.indexOf("-", 6);
if (separator === -1) return null;
return fields[separator + 1] || null;
}
/**
* True when `targetPath` is connected to a mount, in any of three ways:
*
* 1. the path IS a mount point (`-v ~/.codex:/host-home/.codex`)
* 2. the path sits INSIDE a mount point (`/host-home/.codex/profiles`)
* 3. a mount point sits BENEATH the path (`/host-home`, whose children are
* the actual mounts — this is exactly how the compose `host` profile is
* wired, so case 3 is not optional)
*
* Only mounts backed by a filesystem that can hold host data count (see
* NON_HOST_FS_TYPES): a tmpfs/ramfs mount is throwaway storage, not a bind
* mount, so it must not clear the ephemeral flag.
*
* Returns false whenever `/proc/self/mountinfo` is unavailable, which keeps
* host machines (macOS, Windows) on the conservative path.
*/
export function hasBindMountAt(
targetPath: string,
deps: ContainerEnvDeps = defaultDeps()
): boolean {
const target = stripTrailingSlash(String(targetPath || "").trim());
if (!target || !target.startsWith("/") || target === "/") return false;
let content: string;
try {
content = deps.readFileSync("/proc/self/mountinfo", "utf8");
} catch {
return false;
}
for (const line of content.split("\n")) {
// mountinfo field 5 (1-indexed) is the mount point.
const fields = line.split(" ");
if (fields.length < 5) continue;
const mountPoint = stripTrailingSlash(decodeMountPath(fields[4] || ""));
if (!mountPoint || mountPoint === "/") continue;
// An in-memory/pseudo filesystem does not reach the host, so it can never
// stand in for the bind mount the operator was asked to wire up.
const fsType = mountFsType(fields);
if (!fsType || NON_HOST_FS_TYPES.has(fsType)) continue;
if (mountPoint === target) return true;
if (mountPoint.startsWith(`${target}/`)) return true;
if (target.startsWith(`${mountPoint}/`)) return true;
}
return false;
}
export interface ContainerTargetInfo {
inContainer: boolean;
bindMounted: boolean;
/** Writing here would be lost when the container is recreated. */
ephemeral: boolean;
}
/**
* Classify a would-be config write target. `ephemeral` is the signal callers
* act on: refuse the write and point the operator at the host CLI instead.
*/
export function describeContainerTarget(
targetPath: string,
deps: ContainerEnvDeps = defaultDeps()
): ContainerTargetInfo {
const inContainer = isRunningInContainer(deps);
if (!inContainer) {
return { inContainer: false, bindMounted: false, ephemeral: false };
}
const bindMounted = hasBindMountAt(targetPath, deps);
return { inContainer: true, bindMounted, ephemeral: !bindMounted };
}