Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d2689c5
refactor(onboard): extract docker gateway runtime helpers
cv Jun 10, 2026
cfae232
test(onboard): cover docker gateway runtime helpers
cv Jun 10, 2026
75f5a52
docs(onboard): explain docker gateway runtime boundary
cv Jun 10, 2026
128ef56
refactor(onboard): extract dashboard port create resolver
cv Jun 10, 2026
8d9f4c1
test(onboard): document malformed dashboard URL resolution
cv Jun 10, 2026
b6ea38f
refactor(onboard): centralize reused dashboard metadata
cv Jun 10, 2026
2d9d81a
test(onboard): cover disabled Hermes dashboard reuse metadata
cv Jun 10, 2026
318a002
refactor(onboard): extract sandbox registration payload
cv Jun 10, 2026
106d27d
refactor(onboard): extract messaging preparation
cv Jun 10, 2026
45b449b
test(onboard): cover messaging preparation edge cases
cv Jun 10, 2026
97b99bc
refactor(onboard): extract build context staging
cv Jun 10, 2026
a928abd
test(onboard): cover build context staging edge cases
cv Jun 10, 2026
1521702
refactor(onboard): extract sandbox launch envelope
cv Jun 10, 2026
d2c9122
test(onboard): cover sandbox launch shell boundaries
cv Jun 10, 2026
f959ccc
Merge remote-tracking branch 'origin/main' into codex/tmp-dashboard-p…
cv Jun 11, 2026
c06aae6
Merge branch 'codex/tmp-dashboard-port-merge' into codex/tmp-reuse-da…
cv Jun 11, 2026
c66b889
Merge branch 'codex/tmp-reuse-dashboard-merge' into HEAD
cv Jun 11, 2026
c35baff
Merge branch 'codex/tmp-registration-merge' into HEAD
cv Jun 11, 2026
9e0ded0
Merge branch 'codex/tmp-messaging-prep-merge' into HEAD
cv Jun 11, 2026
a59867f
Merge branch 'codex/tmp-build-context-merge' into HEAD
cv Jun 11, 2026
63315c8
Merge remote-tracking branch 'origin/main' into codex/pr-5140-update
cv Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 12 additions & 73 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -185,7 +184,6 @@ type RunnerOptions = {
openshellBinary?: string;
};

const { buildSubprocessEnv } = require("./subprocess-env");
const {
DASHBOARD_PORT,
GATEWAY_PORT,
Expand Down Expand Up @@ -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,
Expand Down
178 changes: 178 additions & 0 deletions src/lib/onboard/sandbox-create-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
});

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 });
}
});
});
99 changes: 99 additions & 0 deletions src/lib/onboard/sandbox-create-launch.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

export interface SandboxCreateLaunch {
createCommand: string;
effectiveDashboardPort: string;
envArgs: string[];
sandboxEnv: Record<string, string>;
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,
};
}
Loading