diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 46617eb403d..018dc8e2bfa 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -36,6 +36,11 @@ const { buildSandboxConfigSyncScript, writeSandboxConfigSyncFile, }: typeof import("./onboard/config-sync") = require("./onboard/config-sync"); +const { + isValidProxyHost, + isValidProxyPort, + patchStagedDockerfile, +}: typeof import("./onboard/dockerfile-patch") = require("./onboard/dockerfile-patch"); const { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, @@ -2362,10 +2367,6 @@ function isOpenclawReady(sandboxName: string): boolean { return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); } -function encodeDockerJsonArg(value: LooseValue): string { - return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); -} - function isAffirmativeAnswer(value: string | null | undefined): boolean { return ["y", "yes"].includes( String(value || "") @@ -2649,204 +2650,6 @@ function verifyWebSearchInsideSandbox( // getSandboxInferenceConfig — moved to onboard-providers.ts -// Shared validators for NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT. -// Both `patchStagedDockerfile()` (build-time Dockerfile ARG override) and -// `createSandbox()` (runtime sandbox env whitelist) must reject the same -// inputs, otherwise the build and runtime paths can diverge — e.g. a -// build-time-accepted value silently no-ops at runtime, leaving the -// container running with the default proxy. Hostname regex deliberately -// excludes `:` so raw IPv6 literals are rejected: the runtime -// `http://${HOST}:${PORT}` template does not bracket them and would -// produce a malformed URL. Port is range-checked because a 5-digit -// length filter alone would accept out-of-range values like 70000. -const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; -function isValidProxyHost(value: string): boolean { - return PROXY_HOST_RE.test(value); -} -function isValidProxyPort(value: string): boolean { - if (!/^[0-9]{1,5}$/.test(value)) return false; - const port = Number(value); - return port >= 1 && port <= 65535; -} - -function patchStagedDockerfile( - dockerfilePath: string, - model: string, - chatUiUrl: string, - buildId = String(Date.now()), - provider: string | null = null, - preferredInferenceApi: string | null = null, - webSearchConfig: WebSearchConfig | null = null, - messagingChannels: string[] = [], - messagingAllowedIds: LooseObject = {}, - discordGuilds: LooseObject = {}, - baseImageRef: string | null = null, - telegramConfig: LooseObject = {}, - darwinVmCompat = false, -) { - const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = - getSandboxInferenceConfig(model, provider, preferredInferenceApi); - let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); - // Pin the base image to a specific digest when available (#1904). - // The ref must come from pullAndResolveBaseImageDigest() — never from - // blueprint.yaml, whose digest belongs to a different registry. - // Only rewrite when the current value already points at our sandbox-base - // image — custom --from Dockerfiles may use a different base. - if (baseImageRef) { - dockerfile = dockerfile.replace( - /^ARG BASE_IMAGE=(.*)$/m, - (line: string, currentValue: string) => { - const trimmed = String(currentValue).trim(); - if ( - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) - ) { - return `ARG BASE_IMAGE=${baseImageRef}`; - } - return line; - }, - ); - } - dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, - `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, - `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, - ); - dockerfile = dockerfile.replace(/^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}`); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, - `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_API=.*$/m, - `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, - `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_BUILD_ID=.*$/m, - `ARG NEMOCLAW_BUILD_ID=${buildId}`, - ); - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DARWIN_VM_COMPAT=.*$/m, - `ARG NEMOCLAW_DARWIN_VM_COMPAT=${darwinVmCompat ? "1" : "0"}`, - ); - // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING - // so the user can tune model metadata without editing the Dockerfile. - const POSITIVE_INT_RE = /^[1-9][0-9]*$/; - const contextWindow = process.env.NEMOCLAW_CONTEXT_WINDOW; - if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, - `ARG NEMOCLAW_CONTEXT_WINDOW=${contextWindow}`, - ); - } - const maxTokens = process.env.NEMOCLAW_MAX_TOKENS; - if (maxTokens && POSITIVE_INT_RE.test(maxTokens)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MAX_TOKENS=.*$/m, - `ARG NEMOCLAW_MAX_TOKENS=${maxTokens}`, - ); - } - const reasoning = process.env.NEMOCLAW_REASONING; - if (reasoning === "true" || reasoning === "false") { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_REASONING=.*$/m, - `ARG NEMOCLAW_REASONING=${reasoning}`, - ); - } - // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's - // model schema currently accepts "text" and "image" only, so validate - // strictly against that vocabulary. Adding modalities to OpenClaw later - // only requires widening this regex. See #2421. - const inferenceInputs = process.env.NEMOCLAW_INFERENCE_INPUTS; - if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, - `ARG NEMOCLAW_INFERENCE_INPUTS=${inferenceInputs}`, - ); - } - // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build - // time. Lets users increase the per-request inference timeout without - // editing the Dockerfile. Ref: issue #2281 - const agentTimeout = process.env.NEMOCLAW_AGENT_TIMEOUT; - if (agentTimeout && POSITIVE_INT_RE.test(agentTimeout)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_AGENT_TIMEOUT=.*$/m, - `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, - ); - } - // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every - // at build time. Accepts Go-style durations with a required s/m/h suffix - // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 - const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; - if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, - `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, - ); - } - // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host - // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. - // Without this, the host export is silently dropped at image build time and - // the sandbox falls back to the default 10.200.0.1:3128 proxy. See #1409. - const proxyHostEnv = process.env.NEMOCLAW_PROXY_HOST; - if (proxyHostEnv && isValidProxyHost(proxyHostEnv)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROXY_HOST=.*$/m, - `ARG NEMOCLAW_PROXY_HOST=${proxyHostEnv}`, - ); - } - const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; - if (proxyPortEnv && isValidProxyPort(proxyPortEnv)) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_PROXY_PORT=.*$/m, - `ARG NEMOCLAW_PROXY_PORT=${proxyPortEnv}`, - ); - } - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, - `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${webSearchConfig ? "1" : "0"}`, - ); - // Onboard flow expects immediate dashboard access without device pairing, - // so disable device auth for images built during onboard (see #1217). - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, - `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, - ); - if (messagingChannels.length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeDockerJsonArg(messagingChannels)}`, - ); - } - if (Object.keys(messagingAllowedIds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, - ); - } - if (Object.keys(discordGuilds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, - `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, - ); - } - if (telegramConfig && Object.keys(telegramConfig).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, - ); - } - fs.writeFileSync(dockerfilePath, dockerfile); -} - // Inference probes — moved to inference/onboard-probes.ts const { hasResponsesToolCall, diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts new file mode 100644 index 00000000000..100b996f853 --- /dev/null +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + encodeDockerJsonArg, + isValidProxyHost, + isValidProxyPort, + patchStagedDockerfile, +} from "../../../dist/lib/onboard/dockerfile-patch"; + +const tmpRoots: string[] = []; + +function dockerfileWith(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dockerfile-patch-test-")); + tmpRoots.push(dir); + const file = path.join(dir, "Dockerfile"); + fs.writeFileSync(file, content, "utf-8"); + return file; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + delete process.env.NEMOCLAW_PROXY_HOST; + delete process.env.NEMOCLAW_PROXY_PORT; +}); + +describe("dockerfile patch helpers", () => { + it("encodes Docker JSON ARG values as base64 JSON", () => { + expect(Buffer.from(encodeDockerJsonArg({ supportsStore: false }), "base64").toString("utf-8")).toBe( + JSON.stringify({ supportsStore: false }), + ); + expect(Buffer.from(encodeDockerJsonArg(null), "base64").toString("utf-8")).toBe("{}"); + expect(Buffer.from(encodeDockerJsonArg(false), "base64").toString("utf-8")).toBe("false"); + }); + + it("validates proxy host and port values", () => { + expect(isValidProxyHost("host.docker.internal")).toBe(true); + expect(isValidProxyHost("10.200.0.1")).toBe(true); + expect(isValidProxyHost("bad:ipv6::host")).toBe(false); + expect(isValidProxyPort("1")).toBe(true); + expect(isValidProxyPort("65535")).toBe(true); + expect(isValidProxyPort("0")).toBe(false); + expect(isValidProxyPort("70000")).toBe(false); + }); + + it("patches base image, inference, proxy, and messaging args", () => { + process.env.NEMOCLAW_PROXY_HOST = "host.docker.internal"; + process.env.NEMOCLAW_PROXY_PORT = "3128"; + const dockerfilePath = dockerfileWith( + [ + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + "ARG NEMOCLAW_PROXY_HOST=old", + "ARG NEMOCLAW_PROXY_PORT=old", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=old", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=old", + "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + { fetchEnabled: true }, + ["telegram"], + { telegram: ["123"] }, + { discord: ["456"] }, + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc", + { requireMention: true }, + true, + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).toContain("ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc"); + expect(patched).toContain("ARG NEMOCLAW_MODEL=custom-model"); + expect(patched).toContain("ARG NEMOCLAW_PROVIDER_KEY=inference"); + expect(patched).toContain("ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/custom-model"); + expect(patched).toContain("ARG CHAT_UI_URL=https://chat.example"); + expect(patched).toContain("ARG NEMOCLAW_INFERENCE_COMPAT_B64="); + expect(patched).toContain("ARG NEMOCLAW_BUILD_ID=build-1"); + expect(patched).toContain("ARG NEMOCLAW_DARWIN_VM_COMPAT=1"); + expect(patched).toContain("ARG NEMOCLAW_PROXY_HOST=host.docker.internal"); + expect(patched).toContain("ARG NEMOCLAW_PROXY_PORT=3128"); + expect(patched).toContain("ARG NEMOCLAW_WEB_SEARCH_ENABLED=1"); + expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); + expect(patched).not.toContain("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old"); + expect(patched).not.toContain("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old"); + }); + + it("uses the shared sandbox inference mapping", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "moonshotai/kimi-k2.6", + "https://chat.example", + "build-1", + "hermes-provider", + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + const compat = patched.match(/^ARG NEMOCLAW_INFERENCE_COMPAT_B64=(.+)$/m)?.[1]; + expect(patched).toContain("ARG NEMOCLAW_PROVIDER_KEY=inference"); + expect(patched).toContain("ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/moonshotai/kimi-k2.6"); + expect(compat).toBeDefined(); + expect(Buffer.from(compat || "", "base64").toString("utf-8")).toBe( + JSON.stringify({ supportsStore: false }), + ); + }); + + it("strips CR/LF from Dockerfile ARG interpolations", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "model\nRUN touch /tmp/model-pwn", + "https://chat.example\r\nRUN touch /tmp/chat-pwn", + "build-1\nRUN touch /tmp/build-pwn", + "compatible-endpoint", + "openai-responses\nRUN touch /tmp/api-pwn", + null, + [], + {}, + {}, + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc\nRUN touch /tmp/base-pwn", + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + expect(patched).not.toMatch(/\r|\nRUN touch/); + expect(patched).toContain("ARG NEMOCLAW_MODEL=modelRUN touch /tmp/model-pwn"); + expect(patched).toContain("ARG CHAT_UI_URL=https://chat.exampleRUN touch /tmp/chat-pwn"); + expect(patched).toContain("ARG NEMOCLAW_BUILD_ID=build-1RUN touch /tmp/build-pwn"); + expect(patched).toContain("ARG NEMOCLAW_INFERENCE_API=openai-responsesRUN touch /tmp/api-pwn"); + expect(patched).toContain( + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abcRUN touch /tmp/base-pwn", + ); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts new file mode 100644 index 00000000000..508b37eecee --- /dev/null +++ b/src/lib/onboard/dockerfile-patch.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { getSandboxInferenceConfig } from "../inference/config"; +import type { WebSearchConfig } from "../inference/web-search"; + +const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; +const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; +const POSITIVE_INT_RE = /^[1-9][0-9]*$/; + +type LooseObject = Record; + +export function encodeDockerJsonArg(value: unknown): string { + return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); +} + +function sanitizeDockerArg(value: unknown): string { + return String(value ?? "").replace(/[\r\n]/g, ""); +} + +function encodeSanitizedDockerJsonArg(value: unknown): string { + return sanitizeDockerArg(encodeDockerJsonArg(value)); +} + +export function isValidProxyHost(value: string): boolean { + return PROXY_HOST_RE.test(value); +} + +export function isValidProxyPort(value: string): boolean { + if (!/^[0-9]{1,5}$/.test(value)) return false; + const port = Number(value); + return port >= 1 && port <= 65535; +} + +export function patchStagedDockerfile( + dockerfilePath: string, + model: string, + chatUiUrl: string, + buildId = String(Date.now()), + provider: string | null = null, + preferredInferenceApi: string | null = null, + webSearchConfig: WebSearchConfig | null = null, + messagingChannels: string[] = [], + messagingAllowedIds: LooseObject = {}, + discordGuilds: LooseObject = {}, + baseImageRef: string | null = null, + telegramConfig: LooseObject = {}, + darwinVmCompat = false, +): void { + const sanitizedModel = sanitizeDockerArg(model); + const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = + getSandboxInferenceConfig(sanitizedModel, provider, preferredInferenceApi); + let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); + // Pin the base image to a specific digest when available (#1904). + // The ref must come from pullAndResolveBaseImageDigest() — never from + // blueprint.yaml, whose digest belongs to a different registry. + // Only rewrite when the current value already points at our sandbox-base + // image — custom --from Dockerfiles may use a different base. + const sanitizedBaseImageRef = baseImageRef ? sanitizeDockerArg(baseImageRef) : null; + if (sanitizedBaseImageRef) { + dockerfile = dockerfile.replace( + /^ARG BASE_IMAGE=(.*)$/m, + (line: string, currentValue: string) => { + const trimmed = String(currentValue).trim(); + if ( + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) + ) { + return `ARG BASE_IMAGE=${sanitizedBaseImageRef}`; + } + return line; + }, + ); + } + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MODEL=.*$/m, + `ARG NEMOCLAW_MODEL=${sanitizedModel}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, + `ARG NEMOCLAW_PROVIDER_KEY=${sanitizeDockerArg(providerKey)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${sanitizeDockerArg(primaryModelRef)}`, + ); + dockerfile = dockerfile.replace( + /^ARG CHAT_UI_URL=.*$/m, + `ARG CHAT_UI_URL=${sanitizeDockerArg(chatUiUrl)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, + `ARG NEMOCLAW_INFERENCE_BASE_URL=${sanitizeDockerArg(inferenceBaseUrl)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_API=.*$/m, + `ARG NEMOCLAW_INFERENCE_API=${sanitizeDockerArg(inferenceApi)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeSanitizedDockerJsonArg(inferenceCompat)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_BUILD_ID=.*$/m, + `ARG NEMOCLAW_BUILD_ID=${sanitizeDockerArg(buildId)}`, + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DARWIN_VM_COMPAT=.*$/m, + `ARG NEMOCLAW_DARWIN_VM_COMPAT=${sanitizeDockerArg(darwinVmCompat ? "1" : "0")}`, + ); + // Honor NEMOCLAW_CONTEXT_WINDOW / NEMOCLAW_MAX_TOKENS / NEMOCLAW_REASONING + // so the user can tune model metadata without editing the Dockerfile. + const contextWindow = process.env.NEMOCLAW_CONTEXT_WINDOW; + if (contextWindow && POSITIVE_INT_RE.test(contextWindow)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_CONTEXT_WINDOW=.*$/m, + `ARG NEMOCLAW_CONTEXT_WINDOW=${sanitizeDockerArg(contextWindow)}`, + ); + } + const maxTokens = process.env.NEMOCLAW_MAX_TOKENS; + if (maxTokens && POSITIVE_INT_RE.test(maxTokens)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MAX_TOKENS=.*$/m, + `ARG NEMOCLAW_MAX_TOKENS=${sanitizeDockerArg(maxTokens)}`, + ); + } + const reasoning = process.env.NEMOCLAW_REASONING; + if (reasoning === "true" || reasoning === "false") { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_REASONING=.*$/m, + `ARG NEMOCLAW_REASONING=${sanitizeDockerArg(reasoning)}`, + ); + } + // Honor NEMOCLAW_INFERENCE_INPUTS for vision-capable models. OpenClaw's + // model schema currently accepts "text" and "image" only, so validate + // strictly against that vocabulary. Adding modalities to OpenClaw later + // only requires widening this regex. See #2421. + const inferenceInputs = process.env.NEMOCLAW_INFERENCE_INPUTS; + if (inferenceInputs && /^(text|image)(,(text|image))*$/.test(inferenceInputs)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_INPUTS=.*$/m, + `ARG NEMOCLAW_INFERENCE_INPUTS=${sanitizeDockerArg(inferenceInputs)}`, + ); + } + // NEMOCLAW_AGENT_TIMEOUT — override agents.defaults.timeoutSeconds at build + // time. Lets users increase the per-request inference timeout without + // editing the Dockerfile. Ref: issue #2281 + const agentTimeout = process.env.NEMOCLAW_AGENT_TIMEOUT; + if (agentTimeout && POSITIVE_INT_RE.test(agentTimeout)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_TIMEOUT=.*$/m, + `ARG NEMOCLAW_AGENT_TIMEOUT=${sanitizeDockerArg(agentTimeout)}`, + ); + } + // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every + // at build time. Accepts Go-style durations with a required s/m/h suffix + // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 + const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, + `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${sanitizeDockerArg(agentHeartbeat)}`, + ); + } + // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host + // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. + // Without this, the host export is silently dropped at image build time and + // the sandbox falls back to the default 10.200.0.1:3128 proxy. See #1409. + const proxyHostEnv = process.env.NEMOCLAW_PROXY_HOST; + if (proxyHostEnv && isValidProxyHost(proxyHostEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_HOST=.*$/m, + `ARG NEMOCLAW_PROXY_HOST=${sanitizeDockerArg(proxyHostEnv)}`, + ); + } + const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; + if (proxyPortEnv && isValidProxyPort(proxyPortEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_PORT=.*$/m, + `ARG NEMOCLAW_PROXY_PORT=${sanitizeDockerArg(proxyPortEnv)}`, + ); + } + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WEB_SEARCH_ENABLED=.*$/m, + `ARG NEMOCLAW_WEB_SEARCH_ENABLED=${sanitizeDockerArg(webSearchConfig ? "1" : "0")}`, + ); + // Onboard flow expects immediate dashboard access without device pairing, + // so disable device auth for images built during onboard (see #1217). + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, + `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, + ); + if (messagingChannels.length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeSanitizedDockerJsonArg(messagingChannels)}`, + ); + } + if (Object.keys(messagingAllowedIds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeSanitizedDockerJsonArg(messagingAllowedIds)}`, + ); + } + if (Object.keys(discordGuilds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, + `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeSanitizedDockerJsonArg(discordGuilds)}`, + ); + } + if (telegramConfig && Object.keys(telegramConfig).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeSanitizedDockerJsonArg(telegramConfig)}`, + ); + } + fs.writeFileSync(dockerfilePath, dockerfile); +}