diff --git a/Dockerfile.base b/Dockerfile.base index 2f9d90577ba..3b9a4338863 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -127,19 +127,15 @@ RUN mkdir -p /sandbox/.openclaw/agents/main/agent \ && chmod -R g+w /sandbox/.openclaw \ && find /sandbox/.openclaw -type d -exec chmod g+s {} + -# Pre-create shell init files for the sandbox user. -# Runtime proxy config is written by the entrypoint to /tmp/nemoclaw-proxy-env.sh -# (root-owned, mode 444, sticky-bit protected) and sourced from here on every -# interactive session. -# Ref: #2181 — the file must not be writable by the sandbox user. +# Pre-create shell init files for the sandbox user. Runtime environment hooks +# are installed system-wide below; user rc files stay clean and locked so +# per-user startup files are not part of the trust boundary. # hadolint ignore=SC2028 RUN printf '%s\n' \ - '# Source runtime proxy config' \ - '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + '# NemoClaw sandbox shell init' \ > /sandbox/.bashrc \ && printf '%s\n' \ - '# Source runtime proxy config' \ - '[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh' \ + '# NemoClaw sandbox login init' \ > /sandbox/.profile \ && chown root:root /sandbox/.bashrc /sandbox/.profile \ && chmod 444 /sandbox/.bashrc /sandbox/.profile diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 75289102520..6b178f5989b 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -105,8 +105,8 @@ System paths remain read-only to prevent agents from: - Modifying DNS resolution or TLS trust stores - Tampering with libraries or shell configuration outside `/sandbox` -The image build pre-creates shell init files `.bashrc` and `.profile`. -These files source runtime proxy configuration from `/tmp/nemoclaw-proxy-env.sh`. +The image build pre-creates locked shell init files `.bashrc` and `.profile` without proxy entries. +Runtime proxy configuration is sourced from system-wide shell hooks that read `/tmp/nemoclaw-proxy-env.sh`. ### Landlock Kernel Requirements diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 862da94d84e..dd2b75569c2 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -194,7 +194,7 @@ The container mounts system directories read-only to prevent the agent from modi ### Agent Config Directory The `/sandbox/.openclaw` directory contains the OpenClaw gateway configuration (model routing, CORS settings, channel config). -The current entrypoint reads the gateway auth token from OpenClaw config when present, exports it as `OPENCLAW_GATEWAY_TOKEN`, and writes it to `/tmp/nemoclaw-proxy-env.sh` so interactive sandbox sessions can reach the gateway through the static `/sandbox/.bashrc` and `/sandbox/.profile` source shims. +The current entrypoint reads the gateway auth token from OpenClaw config when present, exports it as `OPENCLAW_GATEWAY_TOKEN`, and writes it to `/tmp/nemoclaw-proxy-env.sh` so interactive sandbox sessions can reach the gateway through system-wide shell hooks. In root mode, the gateway process still runs as the separate `gateway` user, but the token is intentionally available to sandbox shells for local gateway access. Writable agent state such as plugins, skills, hooks, and workspace metadata lives directly under `/sandbox/.openclaw`. diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 2bcb8125f87..54edab75337 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -21,11 +21,11 @@ _SANDBOX_INIT_LOADED=1 # ── /tmp trust boundary map ────────────────────────────────────── -# Files in /tmp that cross user boundaries. Every file sourced by -# .bashrc/.profile MUST be root-owned 444 in root mode. +# Files in /tmp that cross user boundaries. Every file sourced by system-wide +# shell hooks MUST be root-owned 444 in root mode. # # File Owner Mode Writer Reader Sourced? -# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (.bashrc/.profile) +# /tmp/nemoclaw-proxy-env.sh root 444 root sandbox YES (/etc shell hooks) # /tmp/gateway.log gateway 644 gateway all no (world-readable for diagnostics) # /tmp/auto-pair.log sandbox 600 sandbox sandbox no # /tmp/.npm-cache/ sandbox 755 sandbox sandbox no (tool data) @@ -453,7 +453,35 @@ lock_rc_files() { continue fi if [ -f "$rc_file" ]; then - if ! chmod 444 "$rc_file" 2>/dev/null; then + if ! python3 - "$rc_file" "$(id -u)" <<'PY' 2>/dev/null; then +import errno +import os +import stat +import sys + +path, uid_text = sys.argv[1:3] +uid = int(uid_text) +flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) +try: + fd = os.open(path, flags) +except OSError as exc: + if exc.errno == errno.ELOOP: + print(f"[SECURITY] Refusing to lock symlinked rc file: {path}", file=sys.stderr) + else: + print(f"[SECURITY] Could not open rc file for locking: {path}: {exc}", file=sys.stderr) + sys.exit(1) + +try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + print(f"[SECURITY] Refusing to lock non-regular rc file: {path}", file=sys.stderr) + sys.exit(1) + if uid == 0: + os.fchown(fd, 0, 0) + os.fchmod(fd, 0o444) +finally: + os.close(fd) +PY echo "[SECURITY] Could not lock ${rc_file} to 444 — continuing (best-effort, Landlock may enforce)" >&2 fi fi diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b90d066a2e9..32a61242762 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1601,12 +1601,9 @@ emit_sandbox_sourced_file "$_SECCOMP_GUARD_SCRIPT" <"$_SECCOMP_GUARD_SOURCE" export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SECCOMP_GUARD_SCRIPT" # OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every -# time a user connects via `openshell sandbox connect`. The connect path spawns -# `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT -# ~/.profile or /etc/profile.d/*. -# -# We write dynamic connect-session config to /tmp/nemoclaw-proxy-env.sh. The -# pre-built .bashrc and .profile source this file automatically. +# time a user connects via `openshell sandbox connect`. Dynamic connect-session +# config lives in /tmp/nemoclaw-proxy-env.sh and is sourced by system-wide shell +# hooks from the base image, keeping per-user rc files free of proxy entries. # # SECURITY: The proxy-env file is written via emit_sandbox_sourced_file() # which ensures root:root 444 in root mode (sandbox cannot modify) and @@ -1813,10 +1810,9 @@ GUARDENVEOF # primary process whose exit status is returned). # Each code path below sets these before registering the trap. -# Stale base images may have rc files from before the runtime env source shim -# was baked into Dockerfile.base. Backfill the static shim before lock_rc_files -# makes those files read-only so connect sessions still receive proxy config, -# gateway auth, and command guards through /tmp/nemoclaw-proxy-env.sh. +# Keep per-user rc files out of runtime proxy wiring. Older images and prior +# entrypoint versions wrote a two-line shim into .bashrc/.profile; remove that +# managed stanza before lock_rc_files makes the files read-only again. ensure_runtime_shell_env_shim() { local failed=0 local rc_file @@ -1832,40 +1828,131 @@ ensure_runtime_shell_env_shim() { failed=1 continue fi - if [ -f "$rc_file" ] && grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then + if [ ! -f "$rc_file" ]; then continue fi - if [ "$(id -u)" -eq 0 ] && [ -f "$rc_file" ]; then - if ! chown root:root "$rc_file" 2>/dev/null; then - echo "[SECURITY] could not take ownership of $rc_file before shim backfill" >&2 - failed=1 - continue - fi - if ! chmod 644 "$rc_file" 2>/dev/null; then - echo "[SECURITY] could not make $rc_file writable before shim backfill" >&2 - failed=1 - continue - fi - elif [ -f "$rc_file" ]; then - chmod u+w "$rc_file" 2>/dev/null || true - fi + if ! command python3 - "$rc_file" "$_RUNTIME_SHELL_ENV_SHIM" "$(id -u)" <<'PY'; then +import errno +import os +import stat +import sys +import tempfile + +rc_path, shim, uid_text = sys.argv[1:4] +uid = int(uid_text) +fd = None +tmp_path = None - if [ -e "$rc_file" ]; then - if ! printf '\n%s\n%s\n' '# Source runtime proxy config' "$_RUNTIME_SHELL_ENV_SHIM" >>"$rc_file"; then - echo "[SECURITY] could not backfill runtime env shim into $rc_file" >&2 - failed=1 - continue - fi - elif ! printf '%s\n%s\n' '# Source runtime proxy config' "$_RUNTIME_SHELL_ENV_SHIM" >"$rc_file"; then - echo "[SECURITY] could not create $rc_file with runtime env shim" >&2 - failed=1 - continue - fi - if ! grep -qxF "$_RUNTIME_SHELL_ENV_SHIM" "$rc_file" 2>/dev/null; then - echo "[SECURITY] runtime env shim missing after backfill: $rc_file" >&2 +def same_file(left, right): + return left.st_dev == right.st_dev and left.st_ino == right.st_ino + + +def rewrite_open_rc_file(read_fd, original_stat, cleaned_lines): + # The runtime test image can make /sandbox non-writable while leaving legacy + # shims in the rc files. In that case atomic rename into /sandbox fails, so + # rewrite the already-validated inode through /proc/self/fd instead. + if uid == 0: + os.fchown(read_fd, 0, 0) + os.fchmod(read_fd, 0o600) + write_fd = os.open( + f"/proc/self/fd/{read_fd}", + os.O_WRONLY | os.O_TRUNC | getattr(os, "O_CLOEXEC", 0), + ) + try: + if not same_file(original_stat, os.fstat(write_fd)): + raise RuntimeError("rc file descriptor target changed during cleanup") + with os.fdopen(write_fd, "w", encoding="utf-8", errors="surrogateescape") as handle: + write_fd = None + handle.writelines(cleaned_lines) + handle.flush() + os.fsync(handle.fileno()) + finally: + if write_fd is not None: + os.close(write_fd) + os.fchmod(read_fd, 0o644) + + +def rewrite_by_rename(cleaned_lines): + global tmp_path + tmp_fd, tmp_path = tempfile.mkstemp(prefix="nemoclaw-rc-clean.", dir="/tmp", text=True) + with os.fdopen(tmp_fd, "w", encoding="utf-8", errors="surrogateescape") as handle: + handle.writelines(cleaned_lines) + handle.flush() + os.fsync(handle.fileno()) + if uid == 0: + os.chown(tmp_path, 0, 0) + os.chmod(tmp_path, 0o644) + os.replace(tmp_path, rc_path) + tmp_path = None + +try: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(rc_path, flags) + except OSError as exc: + if exc.errno == errno.ELOOP: + print(f"[SECURITY] refusing symlinked rc file during cleanup: {rc_path}", file=sys.stderr) + else: + print(f"[SECURITY] could not open rc file for cleanup: {rc_path}: {exc}", file=sys.stderr) + sys.exit(1) + + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + print(f"[SECURITY] refusing non-regular rc file during cleanup: {rc_path}", file=sys.stderr) + sys.exit(1) + with os.fdopen(os.dup(fd), "r", encoding="utf-8", errors="surrogateescape") as handle: + lines = handle.readlines() + + cleaned = [] + index = 0 + while index < len(lines): + line = lines[index] + bare = line.rstrip("\n") + if bare == "# Source runtime proxy config": + if index + 1 < len(lines): + next_line = lines[index + 1] + next_bare = next_line.rstrip("\n") + if next_bare == shim or "/tmp/nemoclaw-proxy-env.sh" in next_line: + index += 2 + continue + cleaned.append(line) + cleaned.append(next_line) + index += 2 + continue + if bare == shim or "/tmp/nemoclaw-proxy-env.sh" in line: + index += 1 + continue + cleaned.append(line) + index += 1 + + if any(line.rstrip("\n") == shim or "/tmp/nemoclaw-proxy-env.sh" in line for line in cleaned): + print(f"[SECURITY] runtime env shim still present after cleanup: {rc_path}", file=sys.stderr) + sys.exit(1) + if cleaned == lines: + sys.exit(0) + + try: + rewrite_open_rc_file(fd, st, cleaned) + except OSError as exc: + if exc.errno != errno.ENOENT: + raise + rewrite_by_rename(cleaned) +except Exception as exc: + print(f"[SECURITY] could not safely clean runtime env shim from {rc_path}: {exc}", file=sys.stderr) + sys.exit(1) +finally: + if fd is not None: + os.close(fd) + if tmp_path: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass +PY failed=1 + continue fi done diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index e553786f38d..25ce2678a68 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -376,36 +376,34 @@ else fail "sandbox cannot create new files in .openclaw — should be writable: $OUT" fi -# ── Test 23: .bashrc sources proxy-env from /tmp ────────────────── -# Requires base image with pre-built .bashrc (#804). Skip gracefully -# if the file doesn't exist yet (base image not rebuilt). +# ── Test 23: .bashrc has no proxy entries ──────────────────────── -info "23. .bashrc sources proxy config from /tmp" -OUT=$(run_as_sandbox "cat /sandbox/.bashrc 2>/dev/null || echo MISSING") -if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then - pass ".bashrc sources /tmp/nemoclaw-proxy-env.sh" +info "23. .bashrc has no proxy entries" +OUT=$(run_as_sandbox "if [ ! -f /sandbox/.bashrc ]; then echo MISSING; elif grep -i proxy /sandbox/.bashrc; then echo FOUND; else echo OK; fi") +if echo "$OUT" | grep -qx "OK"; then + pass ".bashrc has no proxy entries" elif echo "$OUT" | grep -q "MISSING\|No such file"; then - info "SKIP: .bashrc not present (base image needs rebuild for #804)" + fail ".bashrc is missing" else - fail ".bashrc does not source from expected path: $OUT" + fail ".bashrc contains proxy entries: $OUT" fi -# ── Test 24: .profile sources proxy-env from /tmp ───────────────── +# ── Test 24: .profile has no proxy entries ─────────────────────── -info "24. .profile sources proxy config from /tmp" -OUT=$(run_as_sandbox "cat /sandbox/.profile 2>/dev/null || echo MISSING") -if echo "$OUT" | grep -q "/tmp/nemoclaw-proxy-env.sh"; then - pass ".profile sources /tmp/nemoclaw-proxy-env.sh" +info "24. .profile has no proxy entries" +OUT=$(run_as_sandbox "if [ ! -f /sandbox/.profile ]; then echo MISSING; elif grep -i proxy /sandbox/.profile; then echo FOUND; else echo OK; fi") +if echo "$OUT" | grep -qx "OK"; then + pass ".profile has no proxy entries" elif echo "$OUT" | grep -q "MISSING\|No such file"; then - info "SKIP: .profile not present (base image needs rebuild for #804)" + fail ".profile is missing" else - fail ".profile does not source from expected path: $OUT" + fail ".profile contains proxy entries: $OUT" fi # ── Test 25: proxy-env.sh is NOT writable by sandbox user (#2181) ── # The entrypoint writes /tmp/nemoclaw-proxy-env.sh via emit_sandbox_sourced_file() # which sets mode 444 and root ownership. The sandbox user must not be able to -# modify this file, as .bashrc/.profile source it on every connect. +# modify this file, as the system-wide shell hooks source it on every connect. # Since the E2E bypasses the entrypoint (--entrypoint ""), we simulate what the # entrypoint does: create the file as root with mode 444, then verify sandbox # cannot modify it. diff --git a/test/repro-2376.test.ts b/test/repro-2376.test.ts index dac45a73ffb..bd27a552c63 100644 --- a/test/repro-2376.test.ts +++ b/test/repro-2376.test.ts @@ -8,9 +8,9 @@ * configuration from /tmp/nemoclaw-proxy-env.sh are missing. * * Root cause: - * The OpenClaw base image (Dockerfile.base) pre-creates /sandbox/.bashrc - * and /sandbox/.profile that source /tmp/nemoclaw-proxy-env.sh — the file - * the entrypoint writes with HERMES_HOME (and proxy vars) at runtime. + * Older OpenClaw base images pre-created /sandbox/.bashrc and + * /sandbox/.profile entries that sourced /tmp/nemoclaw-proxy-env.sh — the + * file the entrypoint writes with HERMES_HOME (and proxy vars) at runtime. * The Hermes base image (agents/hermes/Dockerfile.base) was missing the * equivalent block, so the proxy-env file existed but was never sourced. * diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 7478295b56c..7f6e96a205a 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -376,7 +376,7 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { ); }); - it("provisions unified mutable .openclaw layout and trusted rc shims", () => { + it("provisions unified mutable .openclaw layout and clean trusted rc files", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-layout-")); const sandboxRoot = path.join(tmp, "sandbox"); @@ -418,11 +418,11 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { sandboxRoot, ); expect(rc.result.status).toBe(0); - const runtimeEnvShim = "[ -f /tmp/nemoclaw-proxy-env.sh ] && . /tmp/nemoclaw-proxy-env.sh"; for (const rcName of [".bashrc", ".profile"]) { const rcPath = path.join(sandboxRoot, rcName); const content = fs.readFileSync(rcPath, "utf-8"); - expect(content.split(runtimeEnvShim).length - 1).toBe(1); + expect(content.toLowerCase()).not.toContain("proxy"); + expect(content).not.toContain("/tmp/nemoclaw-proxy-env.sh"); expect((fs.statSync(rcPath).mode & 0o777).toString(8)).toBe("444"); } expect(rc.calls).toContain( diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 73a3cbb409c..6ac6f4c15b4 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -7,7 +7,14 @@ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding, } from "node:child_process"; -import { mkdtempSync, writeFileSync, unlinkSync, readFileSync, lstatSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + writeFileSync, + unlinkSync, + readFileSync, + lstatSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveOpenshell } from "../dist/lib/adapters/openshell/resolve"; @@ -533,15 +540,33 @@ describe("service environment", () => { } }); - it("backfills proxy-env.sh source shims into stale rc files", () => { - const fakeHome = join(tmpdir(), `nemoclaw-rc-shim-test-${process.pid}`); + it("removes legacy proxy-env.sh source shims from sandbox user rc files", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-test-")); const proxyEnvPath = join(fakeHome, "proxy-env.sh"); - const tmpFile = join(tmpdir(), `nemoclaw-rc-shim-write-test-${process.pid}.sh`); - const runtimeEnvShim = `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`; + const tmpFile = join(fakeHome, "rc-shim-write-test.sh"); try { - execFileSync("mkdir", ["-p", fakeHome]); - writeFileSync(join(fakeHome, ".bashrc"), "# old bashrc\n", { mode: 0o644 }); - writeFileSync(join(fakeHome, ".profile"), "# old profile\n", { mode: 0o444 }); + writeFileSync( + join(fakeHome, ".bashrc"), + [ + "# old bashrc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "export PATH=/usr/local/bin:$PATH", + "", + ].join("\n"), + { mode: 0o644 }, + ); + writeFileSync( + join(fakeHome, ".profile"), + [ + "# old profile", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "umask 022", + "", + ].join("\n"), + { mode: 0o444 }, + ); const wrapper = [ "#!/usr/bin/env bash", @@ -556,8 +581,63 @@ describe("service environment", () => { for (const rcName of [".bashrc", ".profile"]) { const rcFile = readFileSync(join(fakeHome, rcName), "utf-8"); - expect(rcFile.split(runtimeEnvShim).length - 1).toBe(1); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + expect(rcFile).toContain(rcName === ".bashrc" ? "export PATH" : "umask 022"); + } + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ } + } + }); + + it("does not follow pre-planted legacy rc cleanup temp symlinks", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-symlink-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const sensitivePath = join(fakeHome, "sensitive"); + const tmpFile = join(fakeHome, "rc-shim-symlink-test.sh"); + try { + writeFileSync( + rcPath, + [ + "# old bashrc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "export PATH=/usr/local/bin:$PATH", + "", + ].join("\n"), + { mode: 0o644 }, + ); + writeFileSync(sensitivePath, "SECRET\n", { mode: 0o600 }); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + 'legacy_tmp="${_SANDBOX_HOME}/.bashrc.nemoclaw-clean.$$"', + `ln -s ${JSON.stringify(sensitivePath)} "$legacy_tmp"`, + extractRuntimeShellEnvShimSnippet(), + "ensure_runtime_shell_env_shim", + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + expect(readFileSync(sensitivePath, "utf-8")).toBe("SECRET\n"); + const rcFile = readFileSync(rcPath, "utf-8"); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + expect(rcFile).toContain("export PATH"); } finally { try { unlinkSync(tmpFile); @@ -572,6 +652,157 @@ describe("service environment", () => { } }); + it("cleans rc shims without shell chown/chmod on the rc path", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-no-path-chmod-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const tmpFile = join(fakeHome, "rc-shim-no-path-chmod-test.sh"); + try { + writeFileSync( + rcPath, + [ + "# old bashrc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "", + ].join("\n"), + { mode: 0o644 }, + ); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + 'chown() { echo "unexpected chown $*" >&2; exit 42; }', + 'chmod() { echo "unexpected chmod $*" >&2; exit 43; }', + extractRuntimeShellEnvShimSnippet(), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const rcFile = readFileSync(rcPath, "utf-8"); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + } finally { + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + + it("does not rewrite locked clean rc files", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-clean-locked-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const profilePath = join(fakeHome, ".profile"); + const tmpFile = join(tmpdir(), `rc-shim-clean-locked-test-${process.pid}.sh`); + try { + writeFileSync(rcPath, "# clean bashrc\n", { mode: 0o444 }); + writeFileSync(profilePath, "# clean profile\n", { mode: 0o444 }); + execFileSync("chmod", ["555", fakeHome]); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + extractRuntimeShellEnvShimSnippet(), + "ensure_runtime_shell_env_shim", + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + expect(readFileSync(rcPath, "utf-8")).toBe("# clean bashrc\n"); + expect(readFileSync(profilePath, "utf-8")).toBe("# clean profile\n"); + } finally { + try { + execFileSync("chmod", ["755", fakeHome]); + } catch { + /* ignore */ + } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + + const itOnProcFd = existsSync("/proc/self/fd") ? it : it.skip; + itOnProcFd("removes legacy rc shims without directory write permission", () => { + const fakeHome = mkdtempSync(join(tmpdir(), "nemoclaw-rc-shim-unwritable-dir-test-")); + const proxyEnvPath = join(fakeHome, "proxy-env.sh"); + const rcPath = join(fakeHome, ".bashrc"); + const profilePath = join(fakeHome, ".profile"); + const tmpFile = join(tmpdir(), `rc-shim-unwritable-dir-test-${process.pid}.sh`); + try { + for (const rcPathToWrite of [rcPath, profilePath]) { + writeFileSync( + rcPathToWrite, + [ + "# old rc", + "# Source runtime proxy config", + `[ -f ${proxyEnvPath} ] && . ${proxyEnvPath}`, + "export PATH=/usr/local/bin:$PATH", + "", + ].join("\n"), + { mode: 0o444 }, + ); + } + execFileSync("chmod", ["555", fakeHome]); + + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `_SANDBOX_HOME=${JSON.stringify(fakeHome)}`, + `_RUNTIME_SHELL_ENV_FILE=${JSON.stringify(proxyEnvPath)}`, + '_RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}"', + extractRuntimeShellEnvShimSnippet(), + "ensure_runtime_shell_env_shim", + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + for (const rcPathToRead of [rcPath, profilePath]) { + const rcFile = readFileSync(rcPathToRead, "utf-8"); + expect(rcFile.toLowerCase()).not.toContain("proxy"); + expect(rcFile).not.toContain(proxyEnvPath); + expect(rcFile).toContain("export PATH"); + } + } finally { + try { + execFileSync("chmod", ["755", fakeHome]); + } catch { + /* ignore */ + } + try { + unlinkSync(tmpFile); + } catch { + /* ignore */ + } + try { + execFileSync("rm", ["-rf", fakeHome]); + } catch { + /* ignore */ + } + } + }); + it("entrypoint overwrites proxy-env.sh cleanly on repeated invocations", () => { const fakeDataDir = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); execFileSync("mkdir", ["-p", fakeDataDir]);