mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* feat(cli): refuse ephemeral container auto-config writes Detect containerized OmniRoute and block CLI/API config writes into throwaway homes unless a bind mount or explicit opt-in is present, and honor compose host-profile CLI_CONFIG_HOME mounts outside the container home. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(changelog): name fragment for #10057 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: yansigit <yansigit@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 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;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*
|
|
* 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;
|
|
|
|
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 };
|
|
}
|