diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e92f0900dd..395858abc0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -29,6 +29,7 @@ const { const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); const extraPlaceholderKeysModule: typeof import("./onboard/extra-placeholder-keys") = require("./onboard/extra-placeholder-keys"); const buildContextStage: typeof import("./onboard/build-context-stage") = require("./onboard/build-context-stage"); +const sandboxCreateLaunch: typeof import("./onboard/sandbox-create-launch") = require("./onboard/sandbox-create-launch"); const { ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); @@ -62,8 +63,6 @@ const { selectResourceProfileForSandbox, }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { - isValidProxyHost, - isValidProxyPort, patchStagedDockerfile, }: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { @@ -185,7 +184,6 @@ type RunnerOptions = { openshellBinary?: string; }; -const { buildSubprocessEnv } = require("./subprocess-env"); const { DASHBOARD_PORT, GATEWAY_PORT, @@ -3136,77 +3134,18 @@ async function createSandbox( sandboxInferenceBaseUrlOverride, hermesToolGateways, ); - // Only pass non-sensitive env vars to the sandbox. Credentials flow through - // OpenShell providers — the gateway injects them as placeholders and the L7 - // proxy rewrites Authorization headers with real secrets at egress. - // See: crates/openshell-sandbox/src/secrets.rs (placeholder rewriting), - // crates/openshell-router/src/backend.rs (inference auth injection). - // - // Use the shared allowlist (subprocess-env.ts) instead of the old - // blocklist. The blocklist only blocked 12 specific credential names - // and passed EVERYTHING else — including GITHUB_TOKEN, - // AWS_SECRET_ACCESS_KEY, SSH_AUTH_SOCK, KUBECONFIG, NPM_TOKEN, and - // any CI/CD secrets that happened to be in the host environment. - // The allowlist inverts the default: only known-safe env vars are forwarded. - // For sandbox create, also strip KUBECONFIG and SSH_AUTH_SOCK: the generic - // allowlist needs them for host-side subprocesses, but sandbox code must not - // access host Kubernetes or SSH-agent credentials. - const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; - // Always pass the effective dashboard port into the sandbox so - // nemoclaw-start.sh starts the gateway on the correct port. When the - // user sets CHAT_UI_URL with a custom port (e.g. :18790), the port - // must reach the container — otherwise _DASHBOARD_PORT defaults to - // 18789 and the gateway listens on the wrong port. (#2267, #1925) - const effectiveDashboardPort = getDashboardForwardPort(chatUiUrl); - envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); - require("./onboard/openclaw-runtime-env").appendOpenClawRuntimeEnvArgs(envArgs, agent); - onboardHermesDashboard.appendHermesDashboardEnvArgs( - envArgs, - hermesDashboardState, - formatEnvAssignment, - ); - require("./onboard/host-proxy-env").appendHostProxyEnvArgs(envArgs); - // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to the runtime - // sandbox container. patchStagedDockerfile() already substitutes them - // into the build-time Dockerfile ARG/ENV, but `openshell sandbox create - // -- env … nemoclaw-start` only forwards the explicitly listed env vars - // — image-baked ENV does not propagate into the running pod. Without - // this, nemoclaw-start.sh:898 falls back to the default 10.200.0.1:3128 - // and `HTTPS_PROXY` inside the sandbox ignores the host override. The - // build-time substitution and runtime env stay in sync as a result. - // Fixes #2424. Uses the shared isValidProxyHost / isValidProxyPort - // helpers so build-time and runtime validation stay aligned. - const sandboxProxyHost = process.env.NEMOCLAW_PROXY_HOST; - if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); - } - const sandboxProxyPort = process.env.NEMOCLAW_PROXY_PORT; - if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { - envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); - } - require("./onboard/extra-placeholder-keys").appendExtraPlaceholderKeysEnvArg( - envArgs, - extraPlaceholderKeys, - formatEnvAssignment, - ); const sandboxReadyTimeoutSecs = getSandboxReadyTimeoutSecs(effectiveSandboxGpuConfig); - const sandboxEnv = buildSubprocessEnv(); - // Remove host-infrastructure credentials that the generic allowlist - // permits for host-side processes but that must not enter the sandbox. - delete sandboxEnv.KUBECONFIG; - delete sandboxEnv.SSH_AUTH_SOCK; - // Run without piping through awk — the pipe masked non-zero exit codes - // from openshell because bash returns the status of the last pipeline - // command (awk, always 0) unless pipefail is set. Removing the pipe - // lets the real exit code flow through to run(). - const sandboxStartupCommand = ["env", ...envArgs, "nemoclaw-start"]; - const createCommand = `${openshellShellCommand([ - "sandbox", - "create", - ...createArgs, - "--", - ...sandboxStartupCommand, - ])} 2>&1`; + const { createCommand, effectiveDashboardPort, sandboxEnv, sandboxStartupCommand } = + sandboxCreateLaunch.prepareSandboxCreateLaunch({ + agent, + chatUiUrl, + createArgs, + env: process.env, + extraPlaceholderKeys, + getDashboardForwardPort, + hermesDashboardState, + openshellShellCommand, + }); const dockerGpuCreatePatch = dockerGpuSandboxCreate.createDockerGpuSandboxCreatePatch({ enabled: useDockerGpuPatch, sandboxName, diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts new file mode 100644 index 0000000000..841edc1730 --- /dev/null +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { createOpenshellCliHelpers } from "../../../dist/lib/onboard/openshell-cli"; +import { prepareSandboxCreateLaunch } from "../../../dist/lib/onboard/sandbox-create-launch"; + +const disabledHermesDashboardState = { config: null, enabled: false }; + +describe("prepareSandboxCreateLaunch", () => { + it("builds the sandbox create command and runtime env envelope", () => { + const openshellShellCommand = vi.fn((args: string[]) => `openshell ${args.join(" ")}`); + const result = prepareSandboxCreateLaunch({ + agent: { name: "openclaw", configPaths: { dir: "/sandbox/.custom-openclaw" } } as any, + chatUiUrl: "http://127.0.0.1:19000/", + createArgs: ["--from", "/tmp/build/Dockerfile", "--name", "demo"], + env: { + HTTP_PROXY: " http://proxy.example:8080 ", + NEMOCLAW_MINIMAL_BOOTSTRAP: "1", + NEMOCLAW_PROXY_HOST: "host.docker.internal", + NEMOCLAW_PROXY_PORT: "3129", + }, + extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand, + buildEnv: () => + ({ + HOME: "/home/user", + KUBECONFIG: "/home/user/.kube/config", + SSH_AUTH_SOCK: "/tmp/agent.sock", + }) as Record, + }); + + expect(result.effectiveDashboardPort).toBe("19000"); + expect(result.envArgs).toEqual([ + "CHAT_UI_URL=http://127.0.0.1:19000/", + "NEMOCLAW_DASHBOARD_PORT=19000", + "OPENCLAW_HOME=/sandbox", + "OPENCLAW_STATE_DIR=/sandbox/.custom-openclaw", + "OPENCLAW_WORKSPACE_DIR=/sandbox/.custom-openclaw/workspace", + "NEMOCLAW_MINIMAL_BOOTSTRAP=1", + "HTTP_PROXY=http://proxy.example:8080", + "NO_PROXY=localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + "no_proxy=localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + "NEMOCLAW_PROXY_HOST=host.docker.internal", + "NEMOCLAW_PROXY_PORT=3129", + "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS=TELEGRAM_BOT_TOKEN_AGENT_A", + ]); + expect(result.sandboxEnv).toEqual({ HOME: "/home/user" }); + expect(result.sandboxStartupCommand).toEqual(["env", ...result.envArgs, "nemoclaw-start"]); + expect(openshellShellCommand).toHaveBeenCalledWith([ + "sandbox", + "create", + "--from", + "/tmp/build/Dockerfile", + "--name", + "demo", + "--", + ...result.sandboxStartupCommand, + ]); + expect(result.createCommand).toBe( + `openshell sandbox create --from /tmp/build/Dockerfile --name demo -- ${result.sandboxStartupCommand.join(" ")} 2>&1`, + ); + }); + + it("adds Hermes dashboard env and skips OpenClaw env for non-OpenClaw agents", () => { + const result = prepareSandboxCreateLaunch({ + agent: { name: "hermes" } as any, + chatUiUrl: "http://127.0.0.1:18789/", + createArgs: [], + env: {}, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "18789", + hermesDashboardState: { + config: { enabled: true, internalPort: 8643, port: 18790, tuiEnabled: true }, + enabled: true, + }, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs).toEqual([ + "CHAT_UI_URL=http://127.0.0.1:18789/", + "NEMOCLAW_DASHBOARD_PORT=18789", + "NEMOCLAW_HERMES_DASHBOARD=1", + "NEMOCLAW_HERMES_DASHBOARD_PORT=18790", + "NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT=8643", + "NEMOCLAW_HERMES_DASHBOARD_TUI=1", + ]); + }); + + it("ignores invalid runtime proxy overrides", () => { + const result = prepareSandboxCreateLaunch({ + agent: null, + chatUiUrl: "http://127.0.0.1:18789/", + createArgs: [], + env: { + NEMOCLAW_PROXY_HOST: "bad:ipv6::host", + NEMOCLAW_PROXY_PORT: "70000", + }, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "18789", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_HOST=bad:ipv6::host"); + expect(result.envArgs).not.toContain("NEMOCLAW_PROXY_PORT=70000"); + }); + + it("preserves argv boundaries when the production renderer shells out", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-launch-shell-")); + try { + const fakeOpenshell = path.join(tmpDir, "fake openshell"); + const capturedArgsPath = path.join(tmpDir, "argv.bin"); + const injectedFromPath = path.join(tmpDir, "from-injected"); + const injectedUrlPath = path.join(tmpDir, "url-injected"); + const injectedProxyPath = path.join(tmpDir, "proxy-injected"); + fs.writeFileSync( + fakeOpenshell, + '#!/usr/bin/env bash\nprintf \'%s\\0\' "$@" > "$CAPTURE_ARGS"\n', + ); + fs.chmodSync(fakeOpenshell, 0o755); + + const helpers = createOpenshellCliHelpers({ + getCachedBinary: () => fakeOpenshell, + setCachedBinary: vi.fn(), + getGatewayPort: () => 31818, + getDockerDriverGatewayEndpoint: () => "http://127.0.0.1:31818", + }); + const dangerousDockerfile = `${tmpDir}/Dockerfile; touch ${injectedFromPath}`; + const dangerousChatUiUrl = `http://127.0.0.1:19000/?q='; touch ${injectedUrlPath} #`; + const dangerousProxy = `http://proxy.example:8080/'; touch ${injectedProxyPath} #`; + const result = prepareSandboxCreateLaunch({ + agent: null, + chatUiUrl: dangerousChatUiUrl, + createArgs: ["--from", dangerousDockerfile, "--name", "demo; echo pwned"], + env: { HTTP_PROXY: dangerousProxy }, + extraPlaceholderKeys: ["TELEGRAM_BOT_TOKEN_AGENT_A"], + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand: helpers.openshellShellCommand, + buildEnv: () => ({}), + }); + + execFileSync("bash", ["-lc", result.createCommand], { + env: { ...process.env, CAPTURE_ARGS: capturedArgsPath }, + }); + + const capturedArgs = fs.readFileSync(capturedArgsPath, "utf-8").split("\0").filter(Boolean); + expect(capturedArgs).toEqual([ + "sandbox", + "create", + "--from", + dangerousDockerfile, + "--name", + "demo; echo pwned", + "--", + "env", + ...result.envArgs, + "nemoclaw-start", + ]); + expect(fs.existsSync(injectedFromPath)).toBe(false); + expect(fs.existsSync(injectedUrlPath)).toBe(false); + expect(fs.existsSync(injectedProxyPath)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts new file mode 100644 index 0000000000..9c224dd88c --- /dev/null +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentDefinition } from "../agent/defs"; +import { formatEnvAssignment } from "../core/url-utils"; +import { buildSubprocessEnv } from "../subprocess-env"; +import { isValidProxyHost, isValidProxyPort } from "./dockerfile-patch"; +import { appendExtraPlaceholderKeysEnvArg } from "./extra-placeholder-keys"; +import type { HermesDashboardOnboardState } from "./hermes-dashboard"; +import { appendHermesDashboardEnvArgs } from "./hermes-dashboard"; +import { appendHostProxyEnvArgs } from "./host-proxy-env"; +import { appendOpenClawRuntimeEnvArgs } from "./openclaw-runtime-env"; + +type OpenshellShellCommand = (args: string[]) => string; + +export interface SandboxCreateLaunchInput { + agent: AgentDefinition | null | undefined; + chatUiUrl: string; + createArgs: readonly string[]; + env?: NodeJS.ProcessEnv; + extraPlaceholderKeys: readonly string[]; + getDashboardForwardPort(chatUiUrl: string): string; + hermesDashboardState: HermesDashboardOnboardState; + openshellShellCommand: OpenshellShellCommand; + buildEnv?(): Record; +} + +export interface SandboxCreateLaunch { + createCommand: string; + effectiveDashboardPort: string; + envArgs: string[]; + sandboxEnv: Record; + sandboxStartupCommand: string[]; +} + +export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): SandboxCreateLaunch { + const env = input.env ?? process.env; + const envArgs = [formatEnvAssignment("CHAT_UI_URL", input.chatUiUrl)]; + + // Always pass the effective dashboard port into the sandbox so + // nemoclaw-start.sh starts the gateway on the correct port. When the + // user sets CHAT_UI_URL with a custom port (e.g. :18790), the port + // must reach the container; otherwise _DASHBOARD_PORT defaults to + // 18789 and the gateway listens on the wrong port. (#2267, #1925) + const effectiveDashboardPort = input.getDashboardForwardPort(input.chatUiUrl); + envArgs.push(formatEnvAssignment("NEMOCLAW_DASHBOARD_PORT", effectiveDashboardPort)); + + appendOpenClawRuntimeEnvArgs(envArgs, input.agent ?? null); + appendHermesDashboardEnvArgs(envArgs, input.hermesDashboardState, formatEnvAssignment); + appendHostProxyEnvArgs(envArgs, env); + + // Propagate NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT to the runtime + // sandbox container. patchStagedDockerfile() already substitutes them + // into the build-time Dockerfile ARG/ENV, but `openshell sandbox create + // -- env ... nemoclaw-start` only forwards the explicitly listed env vars; + // image-baked ENV does not propagate into the running pod. Without + // this, nemoclaw-start.sh falls back to the default 10.200.0.1:3128 + // and `HTTPS_PROXY` inside the sandbox ignores the host override. The + // build-time substitution and runtime env stay in sync as a result. + // Fixes #2424. Uses the shared isValidProxyHost / isValidProxyPort + // helpers so build-time and runtime validation stay aligned. + const sandboxProxyHost = env.NEMOCLAW_PROXY_HOST; + if (sandboxProxyHost && isValidProxyHost(sandboxProxyHost)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_HOST", sandboxProxyHost)); + } + const sandboxProxyPort = env.NEMOCLAW_PROXY_PORT; + if (sandboxProxyPort && isValidProxyPort(sandboxProxyPort)) { + envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); + } + + appendExtraPlaceholderKeysEnvArg(envArgs, input.extraPlaceholderKeys, formatEnvAssignment); + + const sandboxEnv = (input.buildEnv ?? buildSubprocessEnv)(); + // Remove host-infrastructure credentials that the generic allowlist + // permits for host-side processes but that must not enter the sandbox. + delete sandboxEnv.KUBECONFIG; + delete sandboxEnv.SSH_AUTH_SOCK; + + // Run without piping through awk; the pipe masked non-zero exit codes + // from openshell because bash returns the status of the last pipeline + // command (awk, always 0) unless pipefail is set. Removing the pipe + // lets the real exit code flow through to run(). + const sandboxStartupCommand = ["env", ...envArgs, "nemoclaw-start"]; + const createCommand = `${input.openshellShellCommand([ + "sandbox", + "create", + ...input.createArgs, + "--", + ...sandboxStartupCommand, + ])} 2>&1`; + + return { + createCommand, + effectiveDashboardPort, + envArgs, + sandboxEnv, + sandboxStartupCommand, + }; +}