-
Notifications
You must be signed in to change notification settings - Fork 3.1k
test(cli): separate Gemini image acquisition #9947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DockerSpawnSyncResult, "error" | "signal" | "status" | "stderr" | "stdout">; | ||
|
|
||
| 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<DockerImageSetupRunner>): 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<DockerImageSetupRunner>): 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)}`, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DockerImageSetupRunner> { | ||
| 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<DockerImageSetupRunner>() | ||
| .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<DockerImageSetupRunner>().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<DockerImageSetupRunner>() | ||
| .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("<REDACTED>"); | ||
| 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<DockerImageSetupRunner>().mockReturnValue(inspect); | ||
|
|
||
| expect(() => ensureOpenClawGeminiRuntimeImage(PINNED_IMAGE, runDocker)).toThrow( | ||
| `Pinned OpenClaw runtime image inspection ${expected}`, | ||
| ); | ||
| expect(runDocker).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.