diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 46c116ab0ba..eb896286b0b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -529,6 +529,41 @@ If the preset is unknown or not currently applied, the command exits non-zero wi Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox. +### `nemoclaw hosts-add` + +Add a host alias to the sandbox pod template. +Use this when a sandbox needs a stable LAN-only name, such as a local SearXNG or internal model endpoint, without dropping to `docker exec` and `kubectl patch`. + +```console +$ nemoclaw my-assistant hosts-add searxng.local 192.168.1.105 +``` + +The command validates the hostname and IP address, rejects duplicate hostnames, and patches `spec.podTemplate.spec.hostAliases` on the sandbox resource. + +| Flag | Description | +|------|-------------| +| `--dry-run` | Print the JSON patch for the resulting `hostAliases` list without applying it | + +### `nemoclaw hosts-list` + +List host aliases configured on the sandbox resource. + +```console +$ nemoclaw my-assistant hosts-list +``` + +### `nemoclaw hosts-remove` + +Remove a hostname from the sandbox `hostAliases` list. + +```console +$ nemoclaw my-assistant hosts-remove searxng.local +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Print the JSON patch for the resulting `hostAliases` list without applying it | + ### `nemoclaw channels list` List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`) with a short description. diff --git a/src/commands/sandbox/hosts/add.ts b/src/commands/sandbox/hosts/add.ts new file mode 100644 index 00000000000..89596af94d8 --- /dev/null +++ b/src/commands/sandbox/hosts/add.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Command from "../../../lib/commands/sandbox/hosts/add"; +import { withCommandDisplay } from "../../../lib/cli/command-display"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw hosts-add", + description: "Add a sandbox /etc/hosts alias", + flags: "(--dry-run)", + group: "Policy Presets", + scope: "sandbox", + order: 19.1, + }, +]); diff --git a/src/commands/sandbox/hosts/list.ts b/src/commands/sandbox/hosts/list.ts new file mode 100644 index 00000000000..0e16b537819 --- /dev/null +++ b/src/commands/sandbox/hosts/list.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Command from "../../../lib/commands/sandbox/hosts/list"; +import { withCommandDisplay } from "../../../lib/cli/command-display"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw hosts-list", + description: "List sandbox host aliases", + group: "Policy Presets", + scope: "sandbox", + order: 19.2, + }, +]); diff --git a/src/commands/sandbox/hosts/remove.ts b/src/commands/sandbox/hosts/remove.ts new file mode 100644 index 00000000000..8ab8df701f1 --- /dev/null +++ b/src/commands/sandbox/hosts/remove.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import Command from "../../../lib/commands/sandbox/hosts/remove"; +import { withCommandDisplay } from "../../../lib/cli/command-display"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw hosts-remove", + description: "Remove a sandbox /etc/hosts alias", + flags: "(--dry-run)", + group: "Policy Presets", + scope: "sandbox", + order: 19.3, + }, +]); diff --git a/src/lib/actions/sandbox/host-aliases.ts b/src/lib/actions/sandbox/host-aliases.ts new file mode 100644 index 00000000000..18393009091 --- /dev/null +++ b/src/lib/actions/sandbox/host-aliases.ts @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isIP } from "node:net"; + +import { dockerExecFileSync } from "../../adapters/docker"; +import { CLI_NAME } from "../../cli/branding"; + +const K3S_CONTAINER = "openshell-cluster-nemoclaw"; +const HOST_ALIAS_KUBECTL_TIMEOUT_MS = 10_000; + +type HostAlias = { + ip: string; + hostnames: string[]; +}; + +type SandboxResource = { + metadata?: { + resourceVersion?: string; + }; + spec?: { + podTemplate?: { + spec?: { + hostAliases?: unknown; + }; + }; + }; +}; + +type BuildHostAliases = (resource: SandboxResource) => HostAlias[]; + +function validateHostAliasHostname(hostname: string): boolean { + if (!hostname || hostname.length > 253) return false; + return hostname.split(".").every((label) => { + return /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(label); + }); +} + +function normalizeHostAliasHostname(hostname: string): string { + return String(hostname || "").toLowerCase(); +} + +function runKubectlInClusterRaw(args: string[]): string { + return dockerExecFileSync([K3S_CONTAINER, "kubectl", "-n", "openshell", ...args], { + stdio: ["ignore", "pipe", "pipe"], + timeout: HOST_ALIAS_KUBECTL_TIMEOUT_MS, + }); +} + +function throwKubectlError(action: string, error: unknown): never { + const err = error as { stderr?: unknown; stdout?: unknown; message?: unknown; status?: number }; + const detail = String(err?.stderr || err?.stdout || err?.message || "").trim(); + console.error(` Failed to ${action}.${detail ? ` ${detail}` : ""}`); + process.exit(err?.status || 1); +} + +function runKubectlInCluster(args: string[], action: string): string { + try { + return runKubectlInClusterRaw(args); + } catch (error) { + throwKubectlError(action, error); + } +} + +function getSandboxResource(sandboxName: string): SandboxResource { + const raw = runKubectlInCluster(["get", "sandbox", sandboxName, "-o", "json"], "read host aliases"); + try { + return JSON.parse(raw) as SandboxResource; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` Failed to parse sandbox resource: ${message}`); + process.exit(1); + } +} + +function getHostAliases(resource: SandboxResource): unknown[] { + const aliases = resource?.spec?.podTemplate?.spec?.hostAliases; + return Array.isArray(aliases) ? aliases : []; +} + +function normalizeHostAliases(resource: SandboxResource): HostAlias[] { + return getHostAliases(resource).map((alias): HostAlias => { + const entry = alias as { ip?: unknown; hostnames?: unknown }; + return { + ip: typeof entry.ip === "string" ? entry.ip : "", + hostnames: Array.isArray(entry.hostnames) + ? entry.hostnames.map((hostname) => normalizeHostAliasHostname(String(hostname))) + : [], + }; + }); +} + +function buildHostAliasesPatch(resource: SandboxResource, hostAliases: HostAlias[]) { + const patch: Array<{ op: string; path: string; value: unknown }> = []; + const resourceVersion = resource?.metadata?.resourceVersion; + if (resourceVersion) { + patch.push({ + op: "test", + path: "/metadata/resourceVersion", + value: resourceVersion, + }); + } + patch.push({ + op: Array.isArray(resource?.spec?.podTemplate?.spec?.hostAliases) ? "replace" : "add", + path: "/spec/podTemplate/spec/hostAliases", + value: hostAliases, + }); + return patch; +} + +function isHostAliasPatchConflict(error: unknown): boolean { + const err = error as { stderr?: unknown; stdout?: unknown; message?: unknown; status?: number }; + const detail = String(err?.stderr || err?.stdout || err?.message || "").toLowerCase(); + return ( + err?.status === 409 || + detail.includes("conflict") || + detail.includes("resourceversion") || + detail.includes("object has been modified") || + detail.includes("test operation failed") + ); +} + +function patchHostAliases( + sandboxName: string, + resource: SandboxResource, + hostAliases: HostAlias[], +): void { + runKubectlInClusterRaw([ + "patch", + "sandbox", + sandboxName, + "--type=json", + "-p", + JSON.stringify(buildHostAliasesPatch(resource, hostAliases)), + ]); +} + +function patchHostAliasesWithRetry( + sandboxName: string, + buildAliases: BuildHostAliases, + initialResource: SandboxResource, + initialAliases: HostAlias[], +): void { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const resource = attempt === 1 ? initialResource : getSandboxResource(sandboxName); + const aliases = attempt === 1 ? initialAliases : buildAliases(resource); + try { + patchHostAliases(sandboxName, resource, aliases); + return; + } catch (error) { + if (!isHostAliasPatchConflict(error) || attempt === maxAttempts) { + throwKubectlError("update host aliases", error); + } + } + } +} + +export function listSandboxHostAliases(sandboxName: string): void { + const aliases = getHostAliases(getSandboxResource(sandboxName)); + if (aliases.length === 0) { + console.log(` No host aliases configured for '${sandboxName}'.`); + return; + } + + console.log(` Host aliases for '${sandboxName}':`); + for (const alias of aliases) { + const entry = alias as { ip?: unknown; hostnames?: unknown }; + const ip = typeof entry.ip === "string" ? entry.ip : ""; + const hostnames = Array.isArray(entry.hostnames) ? entry.hostnames : []; + if (ip && hostnames.length > 0) { + console.log(` ${ip} ${hostnames.join(", ")}`); + } + } +} + +export function addSandboxHostAlias(sandboxName: string, args: string[] = []): void { + const dryRun = args.includes("--dry-run"); + const values = args.filter((arg) => !arg.startsWith("-")); + const [rawHostname, ip] = values; + if (!rawHostname || !ip || values.length !== 2) { + console.error(` Usage: ${CLI_NAME} hosts-add [--dry-run]`); + process.exit(1); + } + const hostname = normalizeHostAliasHostname(rawHostname); + if (!validateHostAliasHostname(hostname)) { + console.error(` Invalid hostname '${hostname}'.`); + process.exit(1); + } + if (isIP(ip) === 0) { + console.error(` Invalid IP address '${ip}'.`); + process.exit(1); + } + + const resource = getSandboxResource(sandboxName); + const buildAliases: BuildHostAliases = (currentResource) => { + const aliases = normalizeHostAliases(currentResource); + if (aliases.some((alias) => alias.hostnames.includes(hostname))) { + console.error(` Host alias '${hostname}' already exists.`); + process.exit(1); + } + + const existing = aliases.find((alias) => alias.ip === ip); + if (existing) { + existing.hostnames.push(hostname); + } else { + aliases.push({ ip, hostnames: [hostname] }); + } + return aliases; + }; + const aliases = buildAliases(resource); + + if (dryRun) { + console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2)); + return; + } + patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases); + console.log(` Added host alias ${hostname} -> ${ip}`); +} + +export function removeSandboxHostAlias(sandboxName: string, args: string[] = []): void { + const dryRun = args.includes("--dry-run"); + const values = args.filter((arg) => !arg.startsWith("-")); + const [rawHostname] = values; + if (!rawHostname || values.length !== 1) { + console.error(` Usage: ${CLI_NAME} hosts-remove [--dry-run]`); + process.exit(1); + } + const hostname = normalizeHostAliasHostname(rawHostname); + if (!validateHostAliasHostname(hostname)) { + console.error(` Invalid hostname '${hostname}'.`); + process.exit(1); + } + + const resource = getSandboxResource(sandboxName); + const buildAliases: BuildHostAliases = (currentResource) => { + const original = normalizeHostAliases(currentResource); + const aliases = original + .map( + (alias): HostAlias => ({ + ip: alias.ip, + hostnames: alias.hostnames.filter((name) => name !== hostname), + }), + ) + .filter((alias) => alias.ip && alias.hostnames.length > 0); + + const existed = original.some((alias) => alias.hostnames.includes(hostname)); + if (!existed) { + console.error(` Host alias '${hostname}' is not configured.`); + process.exit(1); + } + return aliases; + }; + const aliases = buildAliases(resource); + + if (dryRun) { + console.log(JSON.stringify(buildHostAliasesPatch(resource, aliases), null, 2)); + return; + } + patchHostAliasesWithRetry(sandboxName, buildAliases, resource, aliases); + console.log(` Removed host alias ${hostname}`); +} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index cdcf03266f3..242da5b07c9 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import type { CommandDef } from "./command-registry"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 54 commands", () => { + it("should contain exactly 57 commands", () => { // 25 global (20 visible + 5 hidden help/version aliases) - // 29 sandbox (23 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(54); + // 32 sandbox (26 visible + 6 hidden shields/config) + expect(COMMANDS).toHaveLength(57); }); it("should have no duplicate usage strings", () => { @@ -52,9 +52,9 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 29 entries", () => { - // 23 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(29); + it("should return exactly 32 entries", () => { + // 26 visible + 6 hidden (shields×3 + config get/set/rotate-token) + expect(sandboxCommands()).toHaveLength(32); }); it("every entry has scope sandbox", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 11 hidden commands (43 visible)", () => { + it("should exclude 11 hidden commands (46 visible)", () => { // 5 hidden global (help, --help, -h, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(43); + expect(visibleCommands()).toHaveLength(46); }); it("no visible command has hidden=true", () => { @@ -177,9 +177,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 18 unique action tokens including empty string", () => { + it("returns exactly 21 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(18); + expect(tokens).toHaveLength(21); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "connect", @@ -189,6 +189,9 @@ describe("command-registry", () => { "policy-add", "policy-remove", "policy-list", + "hosts-add", + "hosts-list", + "hosts-remove", "destroy", "skill", "rebuild", diff --git a/src/lib/commands/sandbox/hosts/add.ts b/src/lib/commands/sandbox/hosts/add.ts new file mode 100644 index 00000000000..f2f7a899ed6 --- /dev/null +++ b/src/lib/commands/sandbox/hosts/add.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command } from "@oclif/core"; + +import { buildHostAliasArgs, getHostsRuntimeBridge, hostAliasAddArgs, hostAliasMutationFlags } from "./common"; + +export default class HostsAddCommand extends Command { + static id = "sandbox:hosts:add"; + static strict = true; + static summary = "Add a sandbox /etc/hosts alias"; + static description = "Add a host alias to the sandbox pod template."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox hosts add alpha searxng.local 192.168.1.105"]; + static args = hostAliasAddArgs; + static flags = hostAliasMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(HostsAddCommand); + getHostsRuntimeBridge().addSandboxHostAlias( + args.sandboxName, + buildHostAliasArgs([args.hostname, args.ip], flags), + ); + } +} diff --git a/src/lib/commands/sandbox/hosts/common.ts b/src/lib/commands/sandbox/hosts/common.ts new file mode 100644 index 00000000000..c46fdf94bb3 --- /dev/null +++ b/src/lib/commands/sandbox/hosts/common.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; + +type HostsRuntimeBridge = { + addSandboxHostAlias: (sandboxName: string, args?: string[]) => void; + listSandboxHostAliases: (sandboxName: string) => void; + removeSandboxHostAlias: (sandboxName: string, args?: string[]) => void; +}; + +let runtimeBridgeFactory = (): HostsRuntimeBridge => { + const actions = require("../../../actions/sandbox/host-aliases") as HostsRuntimeBridge; + return actions; +}; + +export function setHostsRuntimeBridgeFactoryForTest(factory: () => HostsRuntimeBridge): void { + runtimeBridgeFactory = factory; +} + +export function getHostsRuntimeBridge(): HostsRuntimeBridge { + return runtimeBridgeFactory(); +} + +const sandboxNameArg = Args.string({ name: "sandbox", description: "Sandbox name", required: true }); +const hostnameArg = Args.string({ name: "hostname", description: "Host alias name", required: true }); +const ipArg = Args.string({ name: "ip", description: "IP address", required: true }); + +export function buildHostAliasArgs( + values: Array, + flags: { "dry-run"?: boolean }, +): string[] { + const args = values.filter((value): value is string => Boolean(value)); + if (flags["dry-run"]) args.push("--dry-run"); + return args; +} + +export const hostAliasSandboxArgs = { + sandboxName: sandboxNameArg, +}; + +export const hostAliasMutationArgs = { + sandboxName: sandboxNameArg, + hostname: hostnameArg, +}; + +export const hostAliasAddArgs = { + ...hostAliasMutationArgs, + ip: ipArg, +}; + +export const hostAliasMutationFlags = { + help: Flags.help({ char: "h" }), + "dry-run": Flags.boolean({ description: "Preview the JSON patch without applying it" }), +}; diff --git a/src/lib/commands/sandbox/hosts/list.ts b/src/lib/commands/sandbox/hosts/list.ts new file mode 100644 index 00000000000..c621e3a3d06 --- /dev/null +++ b/src/lib/commands/sandbox/hosts/list.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { getHostsRuntimeBridge, hostAliasSandboxArgs } from "./common"; + +export default class HostsListCommand extends Command { + static id = "sandbox:hosts:list"; + static strict = true; + static summary = "List sandbox host aliases"; + static description = "List host aliases configured on the sandbox resource."; + static usage = [""]; + static examples = ["<%= config.bin %> sandbox hosts list alpha"]; + static args = hostAliasSandboxArgs; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(HostsListCommand); + getHostsRuntimeBridge().listSandboxHostAliases(args.sandboxName); + } +} diff --git a/src/lib/commands/sandbox/hosts/remove.ts b/src/lib/commands/sandbox/hosts/remove.ts new file mode 100644 index 00000000000..66b85bd5fd9 --- /dev/null +++ b/src/lib/commands/sandbox/hosts/remove.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command } from "@oclif/core"; + +import { buildHostAliasArgs, getHostsRuntimeBridge, hostAliasMutationArgs, hostAliasMutationFlags } from "./common"; + +export default class HostsRemoveCommand extends Command { + static id = "sandbox:hosts:remove"; + static strict = true; + static summary = "Remove a sandbox /etc/hosts alias"; + static description = "Remove a host alias from the sandbox pod template."; + static usage = [" [--dry-run]"]; + static examples = ["<%= config.bin %> sandbox hosts remove alpha searxng.local"]; + static args = hostAliasMutationArgs; + static flags = hostAliasMutationFlags; + + public async run(): Promise { + const { args, flags } = await this.parse(HostsRemoveCommand); + getHostsRuntimeBridge().removeSandboxHostAlias( + args.sandboxName, + buildHostAliasArgs([args.hostname], flags), + ); + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index b21c208b813..81cfc7e4d1d 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -163,7 +163,7 @@ function printConnectOrderHint(candidate: string | null): void { } const VALID_SANDBOX_ACTIONS = - "connect, status, doctor, logs, policy-add, policy-remove, policy-list, skill, snapshot, share, rebuild, recover, shields, config, channels, gateway-token, destroy"; + "connect, status, doctor, logs, policy-add, policy-remove, policy-list, hosts-add, hosts-list, hosts-remove, skill, snapshot, share, rebuild, recover, shields, config, channels, gateway-token, destroy"; function printDispatchUsageError( result: Extract, diff --git a/test/cli.test.ts b/test/cli.test.ts index 02857a8c6b6..65449081391 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -305,6 +305,30 @@ function createDebugCommandTestEnv(prefix: string): Record { }; } +function writeHostAliasDockerStub( + localBin: string, + dockerLog: string, + hostAliases: { ip: string; hostnames: string[] }[], +): void { + const resource = JSON.stringify({ + metadata: { resourceVersion: "123" }, + spec: { podTemplate: { spec: { hostAliases } } }, + }); + fs.writeFileSync( + path.join(localBin, "docker"), + [ + "#!/usr/bin/env bash", + `log_file=${JSON.stringify(dockerLog)}`, + 'printf "%s\\n" "$@" >> "$log_file"', + 'if printf "%s\\n" "$@" | grep -q "^get$"; then', + ` printf "%s\\n" ${JSON.stringify(resource)}`, + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); +} + describe("CLI dispatch", () => { it("config get validates flags and values before dispatch", async () => { const sandboxConfigModule = await import("../dist/lib/sandbox/config.js"); @@ -1485,6 +1509,13 @@ describe("CLI dispatch", () => { expect(policy.out).not.toContain(`sandbox:${action}`); } + for (const action of ["hosts-add", "hosts-list", "hosts-remove"]) { + const hosts = runWithEnv(`alpha ${action} --help`, { HOME: home }); + expect(hosts.code).toBe(0); + expect(hosts.out).toContain(` ${action}`); + expect(hosts.out).not.toContain("sandbox:hosts:"); + } + const channels = runWithEnv("alpha channels list --help", { HOME: home }); expect(channels.code).toBe(0); expect(channels.out).toContain(" channels list"); @@ -1543,6 +1574,250 @@ describe("CLI dispatch", () => { expect(start.out).toContain("Channel 'telegram' is already enabled for 'alpha'. Nothing to do."); }); + it("adds host aliases with a sandbox json patch", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-add-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + fs.writeFileSync( + path.join(localBin, "docker"), + [ + "#!/usr/bin/env bash", + `log_file=${JSON.stringify(dockerLog)}`, + 'printf "%s\\n" "$@" >> "$log_file"', + 'if printf "%s\\n" "$@" | grep -q "^get$"; then', + ' printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\'', + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Added host alias searxng.local -> 192.168.1.105"); + const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); + expect(log).toContain("patch"); + expect(log).toContain("--type=json"); + const patch = JSON.parse(log[log.indexOf("-p") + 1]); + expect(patch[0]).toEqual({ + op: "test", + path: "/metadata/resourceVersion", + value: "123", + }); + expect(patch[1]).toEqual({ + op: "replace", + path: "/spec/podTemplate/spec/hostAliases", + value: [ + { ip: "10.0.0.5", hostnames: ["old.local"] }, + { ip: "192.168.1.105", hostnames: ["searxng.local"] }, + ], + }); + }); + + it("lists host aliases from the sandbox resource", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-list-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + fs.writeFileSync( + path.join(localBin, "docker"), + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" \'{"metadata":{"resourceVersion":"123"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"192.168.1.105","hostnames":["searxng.local","search.lan"]}]}}}}\'', + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha hosts-list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Host aliases for 'alpha'"); + expect(r.out).toContain("192.168.1.105 searxng.local, search.lan"); + }); + + it("removes host aliases with a sandbox json patch", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-remove-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + writeHostAliasDockerStub(localBin, dockerLog, [ + { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, + { ip: "192.168.1.10", hostnames: ["keep.local"] }, + ]); + + const r = runWithEnv("alpha hosts-remove searxng.local", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Removed host alias searxng.local"); + const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); + expect(log).toContain("patch"); + const patch = JSON.parse(log[log.lastIndexOf("-p") + 1]); + expect(patch[0]).toEqual({ + op: "test", + path: "/metadata/resourceVersion", + value: "123", + }); + expect(patch[1]).toEqual({ + op: "replace", + path: "/spec/podTemplate/spec/hostAliases", + value: [ + { ip: "10.0.0.5", hostnames: ["old.local"] }, + { ip: "192.168.1.10", hostnames: ["keep.local"] }, + ], + }); + }); + + it("rejects duplicate host aliases case-insensitively", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-duplicate-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + writeHostAliasDockerStub(localBin, dockerLog, [ + { ip: "10.0.0.5", hostnames: ["SearXNG.local"] }, + ]); + + const r = runWithEnv("alpha hosts-add searxng.local 192.168.1.105", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out).toContain("Host alias 'searxng.local' already exists"); + const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); + expect(log).not.toContain("patch"); + }); + + it("previews host alias changes with dry-run without patching", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-dry-run-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + writeHostAliasDockerStub(localBin, dockerLog, [ + { ip: "10.0.0.5", hostnames: ["searxng.local", "old.local"] }, + ]); + + const add = runWithEnv("alpha hosts-add dry.local 192.168.1.105 --dry-run", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + const remove = runWithEnv("alpha hosts-remove searxng.local --dry-run", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(add.code).toBe(0); + expect(add.out).toContain('\"/metadata/resourceVersion\"'); + expect(add.out).toContain('\"/spec/podTemplate/spec/hostAliases\"'); + expect(add.out).toContain('\"dry.local\"'); + expect(add.out).toContain('\"192.168.1.105\"'); + expect(remove.code).toBe(0); + expect(remove.out).toContain('\"/metadata/resourceVersion\"'); + expect(remove.out).toContain('\"/spec/podTemplate/spec/hostAliases\"'); + expect(remove.out).toContain('\"old.local\"'); + expect(remove.out).not.toContain('\"searxng.local\"'); + const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); + expect(log).not.toContain("patch"); + }); + + it("rejects unknown host alias flags without patching", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-unknown-flag-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + writeHostAliasDockerStub(localBin, dockerLog, [ + { ip: "10.0.0.5", hostnames: ["searxng.local"] }, + ]); + + const add = runWithEnv("alpha hosts-add searxng.local 192.168.1.105 --dry-rnu", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + const remove = runWithEnv("alpha hosts-remove searxng.local --force", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(add.code).toBe(PARSER_EXIT_CODE); + expect(add.out).toContain("Nonexistent flag: --dry-rnu"); + expect(remove.code).toBe(PARSER_EXIT_CODE); + expect(remove.out).toContain("Nonexistent flag: --force"); + expect(fs.existsSync(dockerLog)).toBe(false); + }); + + it("retries host alias patches when the resource version changes", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-hosts-retry-")); + const localBin = path.join(home, "bin"); + const dockerLog = path.join(home, "docker.log"); + const getCount = path.join(home, "get-count"); + const patchCount = path.join(home, "patch-count"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + fs.writeFileSync( + path.join(localBin, "docker"), + [ + "#!/usr/bin/env bash", + `log_file=${JSON.stringify(dockerLog)}`, + `get_count=${JSON.stringify(getCount)}`, + `patch_count=${JSON.stringify(patchCount)}`, + 'printf "%s\\n" "$@" >> "$log_file"', + 'if printf "%s\\n" "$@" | grep -q "^get$"; then', + ' count=$(cat "$get_count" 2>/dev/null || echo 0)', + " count=$((count + 1))", + ' printf "%s" "$count" > "$get_count"', + ' if [ "$count" = "1" ]; then version=123; else version=124; fi', + ' printf \'{"metadata":{"resourceVersion":"%s"},"spec":{"podTemplate":{"spec":{"hostAliases":[{"ip":"10.0.0.5","hostnames":["old.local"]}]}}}}\\n\' "$version"', + " exit 0", + "fi", + 'if printf "%s\\n" "$@" | grep -q "^patch$"; then', + ' count=$(cat "$patch_count" 2>/dev/null || echo 0)', + " count=$((count + 1))", + ' printf "%s" "$count" > "$patch_count"', + ' if [ "$count" = "1" ]; then', + ' echo "Operation cannot be fulfilled: the object has been modified" >&2', + " exit 1", + " fi", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha hosts-add retry.local 192.168.1.105", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Added host alias retry.local -> 192.168.1.105"); + expect(fs.readFileSync(getCount, "utf8")).toBe("2"); + expect(fs.readFileSync(patchCount, "utf8")).toBe("2"); + const log = fs.readFileSync(dockerLog, "utf8").trim().split(/\n/); + const patchArgs = log.filter((line) => line.startsWith("[")); + const finalPatch = patchArgs.at(-1); + expect(finalPatch).toBeDefined(); + expect(JSON.parse(finalPatch!)[0]).toEqual({ + op: "test", + path: "/metadata/resourceVersion", + value: "124", + }); + }); + it("supports oclif-native sandbox command forms", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-native-sandbox-")); writeSandboxRegistry(home);