Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
5 changes: 3 additions & 2 deletions src/lib/agent-onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import os from "os";
import path from "path";

import { ROOT, run } from "./runner";
import { dockerBuild, dockerImageInspect } from "./docker";
import { loadAgent, resolveAgentName, type AgentDefinition } from "./agent-defs";
import { getProviderSelectionConfig } from "./inference-config";
import * as onboardSession from "./onboard-session";
Expand Down Expand Up @@ -61,13 +62,13 @@ export function createAgentSandbox(agent: AgentDefinition): {

if (baseDockerfile) {
const baseImageTag = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base:latest`;
const inspectResult = run(["docker", "image", "inspect", baseImageTag], {
const inspectResult = dockerImageInspect(baseImageTag, {
ignoreError: true,
suppressOutput: true,
});
if (inspectResult.status !== 0) {
console.log(` Building ${agent.displayName} base image (first time only)...`);
run(["docker", "build", "-f", baseDockerfile, "-t", baseImageTag, ROOT], {
dockerBuild(baseDockerfile, baseImageTag, ROOT, {
stdio: ["ignore", "inherit", "inherit"],
});
console.log(` \u2713 Base image built: ${baseImageTag}`);
Expand Down
6 changes: 3 additions & 3 deletions src/lib/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { dockerExecFileSync } from "./docker/exec";
import { platform, tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";

Expand Down Expand Up @@ -255,10 +256,9 @@ function collectDocker(collectDir: string, quick: boolean): void {
// NemoClaw-labelled containers
if (commandExists("docker")) {
try {
const output = execFileSync(
"docker",
const output = dockerExecFileSync(
["ps", "-a", "--filter", "label=com.nvidia.nemoclaw", "--format", "{{.Names}}"],
{ encoding: "utf-8", timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] },
{ timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] },
);
const containers = output.split("\n").filter((c) => c.trim().length > 0);
for (const cid of containers) {
Expand Down
32 changes: 32 additions & 0 deletions src/lib/docker/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { dockerCapture, dockerRun, type DockerCaptureOptions, type DockerRunOptions } from "./run";

export function dockerStop(containerName: string, opts: DockerRunOptions = {}) {
return dockerRun(["stop", containerName], opts);
}

export function dockerRm(containerName: string, opts: DockerRunOptions = {}) {
return dockerRun(["rm", containerName], opts);
}

export function dockerForceRm(containerName: string, opts: DockerRunOptions = {}) {
return dockerRun(["rm", "-f", containerName], opts);
}

export function dockerRunDetached(args: readonly string[], opts: DockerRunOptions = {}) {
return dockerRun(["run", "-d", ...args], opts);
}

export function dockerPort(
containerName: string,
containerPort: string | number,
opts: DockerCaptureOptions = {},
): string {
return dockerCapture(["port", containerName, String(containerPort)], opts);
}

export function dockerExecArgv(containerName: string, cmd: readonly string[]): string[] {
return ["docker", "exec", containerName, ...cmd];
}
22 changes: 22 additions & 0 deletions src/lib/docker/exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execFileSync, spawnSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";

export type DockerExecFileSyncOptions = Omit<ExecFileSyncOptionsWithStringEncoding, "encoding">;
export type DockerSpawnSyncOptions = Parameters<typeof spawnSync>[2];
export type DockerSpawnSyncResult = ReturnType<typeof spawnSync>;

export function dockerExecFileSync(
args: readonly string[],
opts: DockerExecFileSyncOptions = {},
): string {
return String(execFileSync("docker", [...args], { encoding: "utf-8", ...opts }));
}

export function dockerSpawnSync(
args: readonly string[],
opts: DockerSpawnSyncOptions = {},
): DockerSpawnSyncResult {
return spawnSync("docker", [...args], opts);
}
26 changes: 26 additions & 0 deletions src/lib/docker/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ROOT } from "../runner";
import { dockerCapture, dockerRun, type DockerCaptureOptions, type DockerRunOptions } from "./run";

export function dockerBuild(
dockerfilePath: string,
tag: string,
contextDir: string = ROOT,
opts: DockerRunOptions = {},
) {
return dockerRun(["build", "-f", dockerfilePath, "-t", tag, contextDir], opts);
}

export function dockerRmi(imageRef: string, opts: DockerRunOptions = {}) {
return dockerRun(["rmi", imageRef], opts);
}

export function dockerListImagesFormat(
reference: string,
format: string,
opts: DockerCaptureOptions = {},
): string {
return dockerCapture(["images", "--filter", `reference=${reference}`, "--format", format], opts);
}
138 changes: 138 additions & 0 deletions src/lib/docker/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { beforeEach, describe, expect, it, vi } from "vitest";

const runMock = vi.fn();
const runCaptureMock = vi.fn();

vi.mock("../runner", () => ({
ROOT: "/repo/root",
run: (...args: unknown[]) => runMock(...args),
runCapture: (...args: unknown[]) => runCaptureMock(...args),
}));

import {
dockerBuild,
dockerContainerInspectFormat,
dockerInfoFormat,
dockerListVolumesByPrefix,
dockerPull,
dockerRemoveVolumesByPrefix,
dockerRmi,
dockerRunDetached,
} from "./index";

describe("docker helpers", () => {
beforeEach(() => {
runMock.mockReset();
runCaptureMock.mockReset();
runMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
runCaptureMock.mockReturnValue("");
});

it("prefixes docker argv for pull/build/run/rmi helpers", () => {
dockerPull("ghcr.io/example/image:latest");
dockerBuild("Dockerfile", "example:tag", "/tmp/build");
dockerRunDetached(["--name", "example", "busybox:latest"]);
dockerRmi("example:tag");

expect(runMock.mock.calls).toEqual([
[["docker", "pull", "ghcr.io/example/image:latest"], {}],
[["docker", "build", "-f", "Dockerfile", "-t", "example:tag", "/tmp/build"], {}],
[["docker", "run", "-d", "--name", "example", "busybox:latest"], {}],
[["docker", "rmi", "example:tag"], {}],
]);
});

it("prefixes docker argv for info/inspect capture helpers", () => {
dockerInfoFormat("{{.KernelVersion}}", { ignoreError: true });
dockerContainerInspectFormat("{{.State.Status}}", "example-container", {
ignoreError: true,
});

expect(runCaptureMock.mock.calls).toEqual([
[["docker", "info", "--format", "{{.KernelVersion}}"], { ignoreError: true }],
[
[
"docker",
"inspect",
"--type",
"container",
"--format",
"{{.State.Status}}",
"example-container",
],
{ ignoreError: true },
],
]);
});

it("filters docker volume names by exact prefix", () => {
runCaptureMock.mockReturnValue(
[
"openshell-cluster-nemoclaw",
"openshell-cluster-nemoclaw-cache",
"openshell-cluster-nemoclaw-2",
"not-a-match",
"",
].join("\n"),
);

const names = dockerListVolumesByPrefix("openshell-cluster-nemoclaw");

expect(names).toEqual([
"openshell-cluster-nemoclaw",
"openshell-cluster-nemoclaw-cache",
"openshell-cluster-nemoclaw-2",
]);
});

it("removes only volumes returned by the prefix probe", () => {
runCaptureMock.mockReturnValue("openshell-cluster-nemoclaw\nopenshell-cluster-nemoclaw-cache\n");

const removed = dockerRemoveVolumesByPrefix(" openshell-cluster-nemoclaw ", {
ignoreError: true,
});

expect(removed).toEqual([
"openshell-cluster-nemoclaw",
"openshell-cluster-nemoclaw-cache",
]);
expect(runMock).toHaveBeenCalledWith(
[
"docker",
"volume",
"rm",
"openshell-cluster-nemoclaw",
"openshell-cluster-nemoclaw-cache",
],
{ ignoreError: true },
);
});

it("rejects empty volume prefixes", () => {
expect(() => dockerListVolumesByPrefix(" ")).toThrow(/prefix must be a non-empty string/);
expect(() => dockerRemoveVolumesByPrefix("\t")).toThrow(/prefix must be a non-empty string/);
});

it("treats failed volume probes as empty when ignoreError is set", () => {
runCaptureMock.mockImplementation(() => {
throw new Error("docker unavailable");
});

expect(dockerRemoveVolumesByPrefix("openshell-cluster-nemoclaw", { ignoreError: true })).toEqual(
[],
);
expect(runMock).not.toHaveBeenCalled();
});

it("skips volume removal when the probe finds no matches", () => {
runCaptureMock.mockReturnValue("");

const removed = dockerRemoveVolumesByPrefix("openshell-cluster-nemoclaw");

expect(removed).toEqual([]);
expect(runMock).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions src/lib/docker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export * from "./run";
export * from "./exec";
export * from "./pull";
export * from "./info";
export * from "./inspect";
export * from "./image";
export * from "./container";
export * from "./volume";
export * from "./login";
12 changes: 12 additions & 0 deletions src/lib/docker/info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { dockerCapture, type DockerCaptureOptions } from "./run";

export function dockerInfo(opts: DockerCaptureOptions = {}): string {
return dockerCapture(["info"], opts);
}

export function dockerInfoFormat(format: string, opts: DockerCaptureOptions = {}): string {
return dockerCapture(["info", "--format", format], opts);
}
39 changes: 39 additions & 0 deletions src/lib/docker/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { dockerCapture, dockerRun, type DockerCaptureOptions, type DockerRunOptions } from "./run";

export function dockerInspect(args: readonly string[], opts: DockerRunOptions = {}) {
return dockerRun(["inspect", ...args], opts);
}

export function dockerInspectFormat(
format: string,
target: string,
opts: DockerCaptureOptions = {},
): string {
return dockerCapture(["inspect", "--format", format, target], opts);
}

export function dockerImageInspect(
target: string,
opts: DockerRunOptions = {},
) {
return dockerRun(["image", "inspect", target], opts);
}

export function dockerImageInspectFormat(
format: string,
target: string,
opts: DockerCaptureOptions = {},
): string {
return dockerCapture(["image", "inspect", "--format", format, target], opts);
}

export function dockerContainerInspectFormat(
format: string,
containerName: string,
opts: DockerCaptureOptions = {},
): string {
return dockerCapture(["inspect", "--type", "container", "--format", format, containerName], opts);
}
18 changes: 18 additions & 0 deletions src/lib/docker/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { dockerSpawnSync, type DockerSpawnSyncOptions, type DockerSpawnSyncResult } from "./exec";

export function dockerLoginPasswordStdin(
registry: string,
username: string,
password: string,
opts: DockerSpawnSyncOptions = {},
): DockerSpawnSyncResult {
return dockerSpawnSync(["login", registry, "-u", username, "--password-stdin"], {
input: password,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...opts,
});
}
8 changes: 8 additions & 0 deletions src/lib/docker/pull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { dockerRun, type DockerRunOptions } from "./run";

export function dockerPull(imageRef: string, opts: DockerRunOptions = {}) {
return dockerRun(["pull", imageRef], opts);
}
19 changes: 19 additions & 0 deletions src/lib/docker/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { run, runCapture } from "../runner";

export type DockerRunOptions = Parameters<typeof run>[1];
export type DockerCaptureOptions = Parameters<typeof runCapture>[1];

export function dockerArgv(args: readonly string[]): string[] {
return ["docker", ...args];
}

export function dockerRun(args: readonly string[], opts: DockerRunOptions = {}) {
return run(dockerArgv(args), opts);
}

export function dockerCapture(args: readonly string[], opts: DockerCaptureOptions = {}): string {
return runCapture(dockerArgv(args), opts);
}
Loading
Loading