diff --git a/test/helpers/openclaw-gemini-runtime-image.ts b/test/helpers/openclaw-gemini-runtime-image.ts new file mode 100644 index 00000000000..1110a10f794 --- /dev/null +++ b/test/helpers/openclaw-gemini-runtime-image.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { stripVTControlCharacters } from "node:util"; + +import type { + DockerSpawnSyncOptions, + DockerSpawnSyncResult, +} from "../../src/lib/adapters/docker/exec"; +import { dockerSpawnSync } from "../../src/lib/adapters/docker/exec"; +import { redact, redactFull } from "../../src/lib/security/redact"; + +export const OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS = 15_000; +export const OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS = 5 * 60_000; + +const PULL_CAPTURE_MAX_BYTES = 256 * 1024; +const PULL_DIAGNOSTIC_MAX_BYTES = 4 * 1024; +const TRUNCATION_SUFFIX = "\n[diagnostic truncated]"; +const UNSAFE_TERMINAL_CONTROL_CHARACTERS = + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu; + +export type DockerImageSetupRunner = ( + args: readonly string[], + options: DockerSpawnSyncOptions, +) => Pick; + +function outputText(value: unknown): string { + if (Buffer.isBuffer(value)) return value.toString("utf8"); + return typeof value === "string" ? value : ""; +} + +function boundedTail(value: string, maximumBytes: number): string { + if (Buffer.byteLength(value, "utf8") <= maximumBytes) return value; + + const contentLimit = maximumBytes - Buffer.byteLength(TRUNCATION_SUFFIX, "utf8"); + let content = ""; + let contentBytes = 0; + for (const character of Array.from(value).reverse()) { + const characterBytes = Buffer.byteLength(character, "utf8"); + if (contentBytes + characterBytes > contentLimit) break; + content = character + content; + contentBytes += characterBytes; + } + return `${content}${TRUNCATION_SUFFIX}`; +} + +function commandFailureReason(result: ReturnType): string { + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + return code === "ETIMEDOUT" ? "timed out" : "failed to execute"; + } + if (result.signal) return `terminated by ${result.signal}`; + return `exited with status ${String(result.status)}`; +} + +function pullFailureDiagnostic(result: ReturnType): string { + const output = [outputText(result.stderr), outputText(result.stdout)] + .filter((entry) => entry.length > 0) + .join("\n") + .trim(); + return output.length > 0 + ? boundedTail( + redact( + redactFull( + stripVTControlCharacters(output) + .replace(/\r\n?/gu, "\n") + .replace(UNSAFE_TERMINAL_CONTROL_CHARACTERS, ""), + ), + ), + PULL_DIAGNOSTIC_MAX_BYTES, + ) + : "docker pull produced no diagnostic output"; +} + +export function ensureOpenClawGeminiRuntimeImage( + image: string, + runDocker: DockerImageSetupRunner = dockerSpawnSync, +): "cached" | "pulled" { + const inspect = runDocker(["image", "inspect", image], { + stdio: "ignore", + timeout: OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + if (inspect.status === 0) return "cached"; + if (inspect.error || inspect.signal || inspect.status === null) { + throw new Error( + `Pinned OpenClaw runtime image inspection ${commandFailureReason(inspect)}: ${image}`, + ); + } + + const pull = runDocker(["pull", image], { + encoding: "utf8", + timeout: OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS, + killSignal: "SIGKILL", + maxBuffer: PULL_CAPTURE_MAX_BYTES, + }); + if (pull.status === 0) return "pulled"; + + throw new Error( + `Pinned OpenClaw runtime image pull ${commandFailureReason(pull)}: ${image}\n${pullFailureDiagnostic(pull)}`, + ); +} diff --git a/test/openclaw-gemini-inference-compat-runtime.test.ts b/test/openclaw-gemini-inference-compat-runtime.test.ts index f9bdc123f50..61098969722 100644 --- a/test/openclaw-gemini-inference-compat-runtime.test.ts +++ b/test/openclaw-gemini-inference-compat-runtime.test.ts @@ -8,6 +8,11 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { main } from "../scripts/generate-openclaw-config.mts"; import { dockerSpawnSync } from "../src/lib/adapters/docker/exec"; +import { + ensureOpenClawGeminiRuntimeImage, + OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS, + OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS, +} from "./helpers/openclaw-gemini-runtime-image"; const OPENCLAW_RUNTIME_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:f3f0184b96c208c7d50e5a46171a59a6e371f726b06d41972412c36b427a78d4"; @@ -263,6 +268,7 @@ function parseJsonOutput(output: string): unknown { suite("OpenClaw Gemini managed-route runtime compatibility", () => { beforeAll(() => { contextDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gemini-runtime-")); + ensureOpenClawGeminiRuntimeImage(OPENCLAW_RUNTIME_IMAGE); stagedPluginPath = path.join(contextDir, "plugin"); fs.cpSync(PLUGIN_SOURCE_PATH, stagedPluginPath, { recursive: true }); fs.chmodSync(stagedPluginPath, 0o755); @@ -272,7 +278,7 @@ suite("OpenClaw Gemini managed-route runtime compatibility", () => { containerUser = `${pluginStat.uid}:${pluginStat.gid}`; generatedConfigPath = generateConfig(); fs.chmodSync(generatedConfigPath, 0o444); - }); + }, OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS + OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS + 10_000); afterAll(() => { fs.rmSync(contextDir, { recursive: true, force: true }); diff --git a/test/openclaw-gemini-runtime-image.test.ts b/test/openclaw-gemini-runtime-image.test.ts new file mode 100644 index 00000000000..1a0898cc32c --- /dev/null +++ b/test/openclaw-gemini-runtime-image.test.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + ensureOpenClawGeminiRuntimeImage, + OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS, + OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS, + type DockerImageSetupRunner, +} from "./helpers/openclaw-gemini-runtime-image"; + +const PINNED_IMAGE = `ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:${"a".repeat(64)}`; + +function result( + status: number | null, + options: { + error?: NodeJS.ErrnoException; + signal?: NodeJS.Signals | null; + stderr?: string; + stdout?: string; + } = {}, +): ReturnType { + return { + error: options.error, + signal: options.signal ?? null, + status, + stderr: options.stderr ?? "", + stdout: options.stdout ?? "", + }; +} + +describe("OpenClaw Gemini runtime image setup", () => { + it("pulls one cold pinned image before the runtime probe (#9944)", () => { + const runDocker = vi + .fn() + .mockReturnValueOnce(result(1)) + .mockReturnValueOnce(result(0)); + + expect(ensureOpenClawGeminiRuntimeImage(PINNED_IMAGE, runDocker)).toBe("pulled"); + expect(runDocker).toHaveBeenNthCalledWith(1, ["image", "inspect", PINNED_IMAGE], { + stdio: "ignore", + timeout: OPENCLAW_GEMINI_IMAGE_INSPECT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + expect(runDocker).toHaveBeenNthCalledWith( + 2, + ["pull", PINNED_IMAGE], + expect.objectContaining({ + timeout: OPENCLAW_GEMINI_IMAGE_PULL_TIMEOUT_MS, + killSignal: "SIGKILL", + }), + ); + expect(runDocker).toHaveBeenCalledTimes(2); + }); + + it("skips the pull when the pinned image is cached (#9944)", () => { + const runDocker = vi.fn().mockReturnValue(result(0)); + + expect(ensureOpenClawGeminiRuntimeImage(PINNED_IMAGE, runDocker)).toBe("cached"); + expect(runDocker).toHaveBeenCalledOnce(); + }); + + it("stops setup with a bounded pull diagnostic before the runtime probe (#9944)", () => { + const authorizationSecret = "registry-authorization-secret"; + const cookieSecret = "registry-cookie-secret"; + const urlPassword = "registry-url-password"; + const querySecret = "registry-query-secret"; + const runDocker = vi + .fn() + .mockReturnValueOnce(result(1)) + .mockReturnValueOnce( + result(1, { + stderr: [ + "x".repeat(8 * 1024), + "pull access denied", + `\u001b[31mAuthorization: Bearer ${authorizationSecret}\u001b[0m`, + `Cookie: session=${cookieSecret}`, + `https://registry-user:${urlPassword}@registry.example/v2/image?token=${querySecret}`, + "\u001b]0;forged-title\u0007::warning::forged-command\u000b", + ].join("\r\n"), + }), + ); + const runtimeProbe = vi.fn(); + + let failure: Error | undefined; + try { + ensureOpenClawGeminiRuntimeImage(PINNED_IMAGE, runDocker); + runtimeProbe(); + } catch (error) { + failure = error as Error; + } + + expect(failure?.message).toContain("Pinned OpenClaw runtime image pull exited with status 1"); + expect(failure?.message).toContain("pull access denied"); + expect(failure?.message).toContain(""); + expect(failure?.message).toContain("[diagnostic truncated]"); + expect(failure?.message).not.toContain(authorizationSecret); + expect(failure?.message).not.toContain(cookieSecret); + expect(failure?.message).not.toContain(urlPassword); + expect(failure?.message).not.toContain(querySecret); + expect(failure?.message).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/u); + const diagnostic = failure?.message.split("\n").slice(1).join("\n") ?? ""; + expect(Buffer.byteLength(diagnostic, "utf8")).toBeLessThanOrEqual(4 * 1024); + expect(runtimeProbe).not.toHaveBeenCalled(); + expect(runDocker).toHaveBeenCalledTimes(2); + }); + + it.each([ + { + expected: "timed out", + inspect: result(null, { + error: Object.assign(new Error("spawnSync docker ETIMEDOUT"), { code: "ETIMEDOUT" }), + }), + scenario: "times out", + }, + { + expected: "terminated by SIGKILL", + inspect: result(null, { signal: "SIGKILL" }), + scenario: "is terminated", + }, + { + expected: "exited with status null", + inspect: result(null), + scenario: "has no normal exit status", + }, + ])("fails without pulling when image inspection $scenario (#9944)", ({ expected, inspect }) => { + const runDocker = vi.fn().mockReturnValue(inspect); + + expect(() => ensureOpenClawGeminiRuntimeImage(PINNED_IMAGE, runDocker)).toThrow( + `Pinned OpenClaw runtime image inspection ${expected}`, + ); + expect(runDocker).toHaveBeenCalledOnce(); + }); +});