diff --git a/nemoclaw/src/lib/subprocess-env.ts b/nemoclaw/src/lib/subprocess-env.ts index 0949eca8d2b..0ab7a9ad5e5 100644 --- a/nemoclaw/src/lib/subprocess-env.ts +++ b/nemoclaw/src/lib/subprocess-env.ts @@ -28,7 +28,15 @@ const LOCALE = ["LANG"]; // LC_* handled via prefix const PROXY = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"]; -const TLS = ["SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]; +const TLS = [ + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", +]; const TOOLCHAIN = ["DOCKER_HOST", "KUBECONFIG", "SSH_AUTH_SOCK", "RUST_LOG", "RUST_BACKTRACE"]; diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index ac17c89622f..614ec9fd70a 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -939,6 +939,16 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +# Git TLS CA bundle fix (NemoClaw#2270). +# OpenShell's L7 proxy does MITM TLS termination and re-signs with its own CA. +# OpenShell injects SSL_CERT_FILE and CURL_CA_BUNDLE pointing at the CA bundle, +# but git does not read those — it needs GIT_SSL_CAINFO. Without it, git clone +# fails with "server certificate verification failed". +# Use SSL_CERT_FILE (set by OpenShell) as the canonical CA bundle path. +if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then + export GIT_SSL_CAINFO="$SSL_CERT_FILE" +fi + # HTTP library + NODE_USE_ENV_PROXY double-proxy fix (NemoClaw#2109). # Node.js 22 sets NODE_USE_ENV_PROXY=1 in the OpenShell base image, which # intercepts https.request() calls and handles proxying via CONNECT tunnel. @@ -1471,6 +1481,10 @@ PROXYEOF if [ -f "$_WS_FIX_SCRIPT" ]; then echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_WS_FIX_SCRIPT\"" fi + # Git TLS CA bundle for connect sessions (NemoClaw#2270) + if [ -n "${GIT_SSL_CAINFO:-}" ]; then + printf 'export GIT_SSL_CAINFO=%q\n' "$GIT_SSL_CAINFO" + fi # Nemotron inference fix for connect sessions. (NemoClaw#1193, #2051) echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT\"" # ciao network guard for connect sessions. diff --git a/src/lib/subprocess-env.ts b/src/lib/subprocess-env.ts index f77127e8451..8efa17117ec 100644 --- a/src/lib/subprocess-env.ts +++ b/src/lib/subprocess-env.ts @@ -28,7 +28,15 @@ const LOCALE = ["LANG"]; // LC_* handled via prefix const PROXY = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"]; -const TLS = ["SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]; +const TLS = [ + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", +]; const TOOLCHAIN = ["DOCKER_HOST", "KUBECONFIG", "SSH_AUTH_SOCK", "RUST_LOG", "RUST_BACKTRACE"]; diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index 865d5834729..2e2d387bb67 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -93,6 +93,42 @@ describe("credential exposure in process arguments", () => { expect(src).toMatch(/streamSandboxCreate\(createCommand, sandboxEnv(?:, \{)?/); }); + it("subprocess-env TLS allowlist includes git, curl, and python CA vars (#2270)", () => { + const cliSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "subprocess-env.ts"), + "utf-8", + ); + const pluginSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "nemoclaw", "src", "lib", "subprocess-env.ts"), + "utf-8", + ); + for (const src of [cliSrc, pluginSrc]) { + expect(src).toContain("GIT_SSL_CAINFO"); + expect(src).toContain("GIT_SSL_CAPATH"); + expect(src).toContain("CURL_CA_BUNDLE"); + expect(src).toContain("REQUESTS_CA_BUNDLE"); + } + }); + + it("subprocess-env TLS allowlists in CLI and plugin are in sync (#2270)", () => { + const cliSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "subprocess-env.ts"), + "utf-8", + ); + const pluginSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "nemoclaw", "src", "lib", "subprocess-env.ts"), + "utf-8", + ); + // Extract the TLS array from both files and compare + const extractTLS = (src: string) => { + const match = src.match(/const TLS = \[([\s\S]*?)\];/); + if (!match) return ""; + const entries = match[1].match(/"[^"]+"/g) ?? []; + return entries.join(","); + }; + expect(extractTLS(cliSrc)).toBe(extractTLS(pluginSrc)); + }); + it("services.ts must not spread full process.env into subprocess", () => { const src = fs.readFileSync(SERVICES_TS, "utf-8"); diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 8b5822b0d1c..1e07a08c9b4 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -165,6 +165,116 @@ describe("service environment", () => { }); }); + describe("GIT_SSL_CAINFO for proxy CA trust (issue #2270)", () => { + const sandboxInitSource = `source ${JSON.stringify(join(import.meta.dirname, "../scripts/lib/sandbox-init.sh"))}`; + + it("entrypoint exports GIT_SSL_CAINFO when SSL_CERT_FILE points to a real file", () => { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const src = readFileSync(scriptPath, "utf-8"); + // The fix must detect SSL_CERT_FILE and set GIT_SSL_CAINFO so git trusts + // the OpenShell L7 proxy's re-signed certificate. + expect(src).toContain('GIT_SSL_CAINFO="$SSL_CERT_FILE"'); + }); + + it("proxy-env.sh includes GIT_SSL_CAINFO when set", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-git-ssl-test-${process.pid}`); + const fakeCaBundle = join(fakeDataDir, "ca-bundle.pem"); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-git-ssl-env-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file.*\\$_PROXY_ENV_FILE/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!persistBlock.trim()) { + throw new Error( + "sed anchors (_PROXY_ENV_FILE…emit_sandbox_sourced_file) not found in nemoclaw-start.sh — test cannot run", + ); + } + // Create a fake CA bundle so the -f check passes + writeFileSync(fakeCaBundle, "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"); + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + sandboxInitSource, + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_TOOL_REDIRECTS=()', + `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, + `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, + // Simulate OpenShell injecting SSL_CERT_FILE and the entrypoint setting GIT_SSL_CAINFO + `export SSL_CERT_FILE="${fakeCaBundle}"`, + `export GIT_SSL_CAINFO="${fakeCaBundle}"`, + "set +u # array expansion safe on macOS bash", + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).toContain("GIT_SSL_CAINFO"); + expect(envFile).toContain(fakeCaBundle); + } finally { + try { + execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); + } catch { + /* ignore */ + } + } + }); + + it("proxy-env.sh omits GIT_SSL_CAINFO when not set", () => { + const fakeDataDir = join(tmpdir(), `nemoclaw-git-ssl-noop-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeDataDir]); + const tmpFile = join(tmpdir(), `nemoclaw-git-ssl-noop-env-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_ENV_FILE=/,/emit_sandbox_sourced_file.*\\$_PROXY_ENV_FILE/p", scriptPath], + { encoding: "utf-8" }, + ); + if (!persistBlock.trim()) { + throw new Error("sed anchors not found in nemoclaw-start.sh — test cannot run"); + } + const wrapper = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + sandboxInitSource, + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_TOOL_REDIRECTS=()', + `_AXIOS_FIX_SCRIPT="/nonexistent/axios-proxy-fix.js"`, + `_WS_FIX_SCRIPT="/nonexistent/ws-proxy-fix.js"`, + // GIT_SSL_CAINFO intentionally NOT set + "set +u # array expansion safe on macOS bash", + persistBlock + .trimEnd() + .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { encoding: "utf-8" }); + + const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); + expect(envFile).not.toContain("GIT_SSL_CAINFO"); + } finally { + try { + execFileSync("rm", ["-rf", fakeDataDir, tmpFile]); + } catch { + /* ignore */ + } + } + }); + }); + describe("XDG and tool cache redirects (issue #804)", () => { it("entrypoint exports redirect all XDG and tool dirs to /tmp", () => { const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");