Skip to content
10 changes: 9 additions & 1 deletion nemoclaw/src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down
14 changes: 14 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,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

# axios + 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 all https.request() calls and handles proxy via CONNECT tunnel.
Expand Down Expand Up @@ -853,6 +863,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
echo "export GIT_SSL_CAINFO=\"$GIT_SSL_CAINFO\""
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth)
echo '# Tool cache redirects — /sandbox is Landlock read-only (#804)'
for _redir in "${_TOOL_REDIRECTS[@]}"; do
Expand Down
10 changes: 9 additions & 1 deletion src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down
34 changes: 34 additions & 0 deletions test/credential-exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,40 @@ 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) => {
const match = src.match(/const TLS = \[([\s\S]*?)\];/);
return match ? match[1].replace(/\s/g, "") : "";
};
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");

Expand Down
110 changes: 110 additions & 0 deletions test/service-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,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");
Expand Down
Loading