|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import { spawnSync } from "node:child_process"; |
| 5 | +import fs from "node:fs"; |
| 6 | +import os from "node:os"; |
| 7 | +import path from "node:path"; |
| 8 | + |
| 9 | +import { DOCKER_DESKTOP_CREDENTIAL_STORE_NAMES } from "../../domain/docker-host"; |
| 10 | +import { isWsl } from "../../platform"; |
| 11 | +import { buildSubprocessEnv } from "../../subprocess-env"; |
| 12 | +import { |
| 13 | + dockerDesktopCredentialHelperResponds, |
| 14 | + readDockerCredentialStore, |
| 15 | +} from "./credential-store"; |
| 16 | +import { dockerSpawnSync } from "./exec"; |
| 17 | + |
| 18 | +const DOCKER_ENV_NAMES = [ |
| 19 | + "CONTAINERS_CONF", |
| 20 | + "DOCKER_API_VERSION", |
| 21 | + "DOCKER_CERT_PATH", |
| 22 | + "DOCKER_CONFIG", |
| 23 | + "DOCKER_CONTEXT", |
| 24 | + "DOCKER_HOST", |
| 25 | + "DOCKER_TLS_VERIFY", |
| 26 | +] as const; |
| 27 | + |
| 28 | +export interface DockerBuildEnvironmentInput { |
| 29 | + env?: NodeJS.ProcessEnv; |
| 30 | + credentialHelperResponds?: (credsStore: string) => boolean; |
| 31 | + dockerContextIsDefault?: (env: NodeJS.ProcessEnv) => boolean; |
| 32 | + isWslHost?: boolean; |
| 33 | + allowCredentialIsolation?: boolean; |
| 34 | +} |
| 35 | + |
| 36 | +export interface PreparedDockerBuildEnvironment { |
| 37 | + env: NodeJS.ProcessEnv; |
| 38 | + isolatedCredentialConfig: boolean; |
| 39 | + cleanup(): DockerBuildEnvironmentCleanupResult; |
| 40 | +} |
| 41 | + |
| 42 | +export type DockerBuildEnvironmentCleanupResult = |
| 43 | + | { ok: true } |
| 44 | + | { ok: false; directory: string; error: string }; |
| 45 | + |
| 46 | +export function createCredentialFreeDockerConfig(purpose: "portable" | "wsl-buildkit"): string { |
| 47 | + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-${purpose}-docker-config-`)); |
| 48 | + fs.chmodSync(directory, 0o700); |
| 49 | + fs.writeFileSync(path.join(directory, "config.json"), '{"auths":{}}\n', { |
| 50 | + encoding: "utf-8", |
| 51 | + flag: "wx", |
| 52 | + mode: 0o600, |
| 53 | + }); |
| 54 | + return directory; |
| 55 | +} |
| 56 | + |
| 57 | +/** Restrict the host Docker build to environment values used by Docker itself. */ |
| 58 | +export function dockerBuildSubprocessEnv( |
| 59 | + sourceEnv: NodeJS.ProcessEnv = process.env, |
| 60 | +): Record<string, string> { |
| 61 | + const env = buildSubprocessEnv(); |
| 62 | + for (const key of DOCKER_ENV_NAMES) { |
| 63 | + const value = sourceEnv[key]; |
| 64 | + // sourceEnv owns Docker daemon and client selection. Do not let a Docker |
| 65 | + // variable omitted by the caller leak back in from the parent process. |
| 66 | + if (value === undefined) delete env[key]; |
| 67 | + else env[key] = value; |
| 68 | + } |
| 69 | + for (const key of Object.keys(env)) { |
| 70 | + if ( |
| 71 | + key === "KUBECONFIG" || |
| 72 | + key === "SSH_AUTH_SOCK" || |
| 73 | + key === "RUST_LOG" || |
| 74 | + key === "RUST_BACKTRACE" || |
| 75 | + key.startsWith("OPENSHELL_") || |
| 76 | + key.startsWith("GRPC_") |
| 77 | + ) { |
| 78 | + delete env[key]; |
| 79 | + } |
| 80 | + } |
| 81 | + // Match the runner and Docker probe contract: an explicitly selected host |
| 82 | + // owns daemon authority, so an ambient context must not redirect the build. |
| 83 | + // Keep DOCKER_CONFIG because the selected daemon can still require registry |
| 84 | + // credentials or client certificates from that configuration. |
| 85 | + if (env.DOCKER_HOST !== undefined) { |
| 86 | + delete env.DOCKER_CONTEXT; |
| 87 | + } |
| 88 | + return env; |
| 89 | +} |
| 90 | + |
| 91 | +function requiresCredentialFreeWslBuildConfig( |
| 92 | + env: NodeJS.ProcessEnv, |
| 93 | + helperResponds: (credsStore: string) => boolean, |
| 94 | + isWslHost?: boolean, |
| 95 | +): boolean { |
| 96 | + if (!isWsl({ env, isWsl: isWslHost })) return false; |
| 97 | + const { credsStore } = readDockerCredentialStore(env, fs.readFileSync); |
| 98 | + return ( |
| 99 | + credsStore !== undefined && |
| 100 | + DOCKER_DESKTOP_CREDENTIAL_STORE_NAMES.has(credsStore) && |
| 101 | + !helperResponds(credsStore) |
| 102 | + ); |
| 103 | +} |
| 104 | + |
| 105 | +function dockerDesktopCredentialHelperRespondsFromBuild( |
| 106 | + credsStore: string, |
| 107 | + env: NodeJS.ProcessEnv, |
| 108 | +): boolean { |
| 109 | + return dockerDesktopCredentialHelperResponds(credsStore, (command, options) => { |
| 110 | + const [executable, ...args] = command; |
| 111 | + if (!executable) return null; |
| 112 | + const result = spawnSync(executable, args, { |
| 113 | + encoding: "utf-8", |
| 114 | + env: dockerBuildSubprocessEnv(env), |
| 115 | + shell: false, |
| 116 | + stdio: ["ignore", "pipe", "ignore"], |
| 117 | + timeout: options?.timeout, |
| 118 | + }); |
| 119 | + return result.error || result.status !== 0 ? null : result.stdout; |
| 120 | + }); |
| 121 | +} |
| 122 | + |
| 123 | +function dockerContextIsDefaultFromBuild(env: NodeJS.ProcessEnv): boolean { |
| 124 | + // Any explicit endpoint owns daemon authority, including alternate Unix |
| 125 | + // sockets. Preserve its client configuration and registry credentials. |
| 126 | + if (env.DOCKER_HOST) return false; |
| 127 | + const result = dockerSpawnSync(["context", "show"], { |
| 128 | + encoding: "utf-8", |
| 129 | + env: dockerBuildSubprocessEnv(env), |
| 130 | + shell: false, |
| 131 | + stdio: ["ignore", "pipe", "ignore"], |
| 132 | + timeout: 5_000, |
| 133 | + }); |
| 134 | + return !result.error && result.status === 0 && String(result.stdout).trim() === "default"; |
| 135 | +} |
| 136 | + |
| 137 | +/** |
| 138 | + * Prepare the Docker environment shared by normal creation and rebuild image |
| 139 | + * preflight. Docker Desktop can leave WSL pointing at a Windows credential |
| 140 | + * helper that is unavailable from the current session; generated image builds |
| 141 | + * do not need registry credentials, so isolate that case without modifying the |
| 142 | + * user's Docker config. |
| 143 | + */ |
| 144 | +export function prepareDockerBuildEnvironment( |
| 145 | + input: DockerBuildEnvironmentInput = {}, |
| 146 | +): PreparedDockerBuildEnvironment { |
| 147 | + const sourceEnv = input.env ?? process.env; |
| 148 | + const helperResponds = |
| 149 | + input.credentialHelperResponds ?? |
| 150 | + ((credsStore: string) => dockerDesktopCredentialHelperRespondsFromBuild(credsStore, sourceEnv)); |
| 151 | + const contextIsDefault = input.dockerContextIsDefault ?? dockerContextIsDefaultFromBuild; |
| 152 | + const credentialFreeConfig = |
| 153 | + input.allowCredentialIsolation !== false && |
| 154 | + contextIsDefault(sourceEnv) && |
| 155 | + requiresCredentialFreeWslBuildConfig(sourceEnv, helperResponds, input.isWslHost) |
| 156 | + ? createCredentialFreeDockerConfig("wsl-buildkit") |
| 157 | + : null; |
| 158 | + return { |
| 159 | + env: { |
| 160 | + ...dockerBuildSubprocessEnv(sourceEnv), |
| 161 | + DOCKER_BUILDKIT: "1", |
| 162 | + ...(credentialFreeConfig ? { DOCKER_CONFIG: credentialFreeConfig } : {}), |
| 163 | + }, |
| 164 | + isolatedCredentialConfig: credentialFreeConfig !== null, |
| 165 | + cleanup: () => { |
| 166 | + if (credentialFreeConfig === null) return { ok: true }; |
| 167 | + try { |
| 168 | + fs.rmSync(credentialFreeConfig, { recursive: true, force: true }); |
| 169 | + return { ok: true }; |
| 170 | + } catch (error) { |
| 171 | + return { |
| 172 | + ok: false, |
| 173 | + directory: credentialFreeConfig, |
| 174 | + error: error instanceof Error ? error.message : String(error), |
| 175 | + }; |
| 176 | + } |
| 177 | + }, |
| 178 | + }; |
| 179 | +} |
| 180 | + |
| 181 | +function boundedCleanupDiagnostic(value: string): string { |
| 182 | + return value.replace(/[^\x20-\x7E]/gu, "?").slice(0, 240); |
| 183 | +} |
| 184 | + |
| 185 | +/** Warn without replacing the Docker operation result when temporary cleanup fails. */ |
| 186 | +export function warnIfDockerBuildEnvironmentCleanupFailed( |
| 187 | + result: DockerBuildEnvironmentCleanupResult, |
| 188 | + operation: string, |
| 189 | + warn: (message: string) => void = console.warn, |
| 190 | +): void { |
| 191 | + if (result.ok) return; |
| 192 | + const directory = boundedCleanupDiagnostic(result.directory); |
| 193 | + const operationLabel = boundedCleanupDiagnostic(operation); |
| 194 | + const detail = boundedCleanupDiagnostic(result.error); |
| 195 | + try { |
| 196 | + warn( |
| 197 | + ` Warning: failed to remove credential-free Docker config '${directory}' after ${operationLabel}: ${detail}. It contains no credentials and can be removed after Docker no longer uses it.`, |
| 198 | + ); |
| 199 | + } catch { |
| 200 | + // Cleanup diagnostics must never replace the Docker operation result. |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +/** Overlay a credential-free Docker client config onto a host subprocess env. */ |
| 205 | +export function mergeIsolatedDockerClientEnv( |
| 206 | + targetEnv: NodeJS.ProcessEnv, |
| 207 | + prepared: PreparedDockerBuildEnvironment, |
| 208 | +): NodeJS.ProcessEnv { |
| 209 | + const isolatedConfig = prepared.isolatedCredentialConfig ? prepared.env.DOCKER_CONFIG : undefined; |
| 210 | + return isolatedConfig === undefined ? targetEnv : { ...targetEnv, DOCKER_CONFIG: isolatedConfig }; |
| 211 | +} |
0 commit comments