From d06c9acd15cfda40f073bc1e35ea463a438bb47f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 13 Jul 2026 17:13:54 -0700 Subject: [PATCH 1/5] refactor(openshell): remove sandbox DNS proxy Signed-off-by: Aaron Erickson --- docs/reference/commands.mdx | 1 - scripts/setup-dns-proxy.sh | 24 - src/commands/internal/dns/setup-proxy.ts | 30 -- src/lib/actions/README.md | 2 +- src/lib/actions/dns/index.test.ts | 110 +---- src/lib/actions/dns/index.ts | 462 +----------------- .../connect-flow-dcode-probe-preamble.test.ts | 3 - src/lib/actions/sandbox/connect-flow.test.ts | 4 - .../sandbox/connect-route-containment.test.ts | 13 +- .../sandbox/connect-route-lifecycle.test.ts | 2 - .../connect-route-repair-inconclusive.test.ts | 54 +- .../sandbox/connect-route-repair.test.ts | 95 +--- src/lib/actions/sandbox/connect.ts | 149 +----- src/lib/actions/sandbox/snapshot.ts | 12 +- src/lib/domain/dns/setup-proxy.test.ts | 42 -- src/lib/domain/dns/setup-proxy.ts | 89 ---- src/lib/onboard.ts | 11 +- src/lib/platform.ts | 2 +- test/dns-proxy.test.ts | 103 +--- test/internal-cli.test.ts | 12 - test/sandbox-connect-inference/helpers.ts | 44 -- .../route-swap-repair.test.ts | 2 - test/shellquote-sandbox.test.ts | 124 ----- test/support/connect-flow-test-harness.ts | 4 - 24 files changed, 46 insertions(+), 1348 deletions(-) delete mode 100755 scripts/setup-dns-proxy.sh delete mode 100644 src/commands/internal/dns/setup-proxy.ts delete mode 100644 src/lib/domain/dns/setup-proxy.test.ts delete mode 100644 src/lib/domain/dns/setup-proxy.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index ee9adb5c54d..f8b9a1f7ce4 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3121,7 +3121,6 @@ For contributor guidance on how these command files are structured, refer to | `$$nemoclaw internal uninstall plan` | `uninstall.sh` / `$$nemoclaw uninstall` | Build a deterministic uninstall plan without applying it. | | `$$nemoclaw internal uninstall run-plan` | `uninstall.sh` / `$$nemoclaw uninstall` | Remove host-side NemoClaw resources from a previously built plan. | | `$$nemoclaw internal uninstall classify-shim` | `uninstall.sh` / `$$nemoclaw uninstall` | Classify whether a shim path is safe for the uninstaller to remove. | -| `$$nemoclaw internal dns setup-proxy` | onboarding / sandbox setup | Configure the DNS forwarder bridge inside a sandbox pod. | | `$$nemoclaw internal dns fix-coredns` | onboarding / sandbox setup | Patch CoreDNS to use a non-loopback upstream resolver. | | `$$nemoclaw internal dev npm-link-or-shim` | `scripts/npm-link-or-shim.sh` (development) | Run `npm link`, falling back to a user-local NemoClaw development shim. | diff --git a/scripts/setup-dns-proxy.sh b/scripts/setup-dns-proxy.sh deleted file mode 100755 index ff8ba4a7e25..00000000000 --- a/scripts/setup-dns-proxy.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Compatibility wrapper for the TypeScript sandbox DNS proxy setup. -# -# Usage: ./scripts/setup-dns-proxy.sh [gateway-name] - -set -euo pipefail - -if [ "$#" -lt 2 ]; then - echo "Usage: $0 [gateway-name] " - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CLI_JS="${NEMOCLAW_CLI_JS:-$REPO_ROOT/dist/nemoclaw.js}" - -if [ -f "$CLI_JS" ]; then - exec node "$CLI_JS" internal dns setup-proxy "$@" -fi - -exec nemoclaw internal dns setup-proxy "$@" diff --git a/src/commands/internal/dns/setup-proxy.ts b/src/commands/internal/dns/setup-proxy.ts deleted file mode 100644 index 5dceb256bf2..00000000000 --- a/src/commands/internal/dns/setup-proxy.ts +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Args } from "@oclif/core"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; - -import { runSetupDnsProxy } from "../../../lib/actions/dns"; - -export default class InternalDnsSetupProxyCommand extends NemoClawCommand { - static hidden = true; - static strict = true; - static summary = "Internal: configure sandbox DNS proxy"; - static description = "Configure the DNS forwarder bridge inside a sandbox pod."; - static usage = ["internal dns setup-proxy "]; - static examples = ["<%= config.bin %> internal dns setup-proxy nemoclaw my-sandbox"]; - static args = { - gatewayName: Args.string({ description: "OpenShell gateway name", required: true }), - sandboxName: Args.string({ description: "Sandbox name", required: true }), - }; - static flags = {}; - - public async run(): Promise { - const { args } = await this.parse(InternalDnsSetupProxyCommand); - const result = runSetupDnsProxy({ - gatewayName: args.gatewayName, - sandboxName: args.sandboxName, - }); - this.applyExitResult(result); - } -} diff --git a/src/lib/actions/README.md b/src/lib/actions/README.md index 47b21f625fb..95deb25c824 100644 --- a/src/lib/actions/README.md +++ b/src/lib/actions/README.md @@ -15,7 +15,7 @@ src/lib/actions//.test.ts Examples: ```text -dns/index.ts # internal dns fix-coredns/setup-proxy orchestration +dns/index.ts # internal dns fix-coredns orchestration dev/npm-link-or-shim.ts # prepare-time dev shim orchestration installer/plan.ts # deterministic installer planning uninstall/plan.ts # host uninstall planning diff --git a/src/lib/actions/dns/index.test.ts b/src/lib/actions/dns/index.test.ts index 3a290de9e11..3d0da920d63 100644 --- a/src/lib/actions/dns/index.test.ts +++ b/src/lib/actions/dns/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CommandResult } from "./index"; -import { runFixCoreDns, runSetupDnsProxy } from "./index.js"; +import { runFixCoreDns } from "./index.js"; function ok(stdout = ""): CommandResult { return { status: 0, stdout, stderr: "" }; @@ -157,111 +157,3 @@ describe("runFixCoreDns", () => { expect(result.message).toBe("patch failed"); }); }); - -describe("runSetupDnsProxy", () => { - it("configures the DNS proxy through kubectl-in-docker argv calls", () => { - const calls: string[][] = []; - const log = vi.fn(); - const runDocker = vi.fn((args: string[]) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok("10.43.0.10"); - if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); - if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box[1]-abc\n"); - if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); - if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); - if (cmd.includes("cat /tmp/dns-proxy.log")) - return ok("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\n"); - if (cmd.includes("python3 -c")) return ok("ok"); - if (cmd.includes("ls /run/netns/")) return ok("sandbox-ns\n"); - if (cmd.includes("test -x")) return ok(); - if (cmd.includes("cat /etc/resolv.conf")) return ok("nameserver 10.200.0.1\n"); - if (cmd.includes("getent hosts github.com")) return ok("140.82.112.4 github.com\n"); - return ok(); - }); - - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box[1]" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - log, - runDocker, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(0); - expect(result.dnsUpstream).toBe("10.43.0.10"); - expect(result.pod).toBe("box[1]-abc"); - expect(result.verificationPass).toBe(4); - expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); - expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(false); - expect(calls.some((args) => args.includes("box[1]-abc"))).toBe(true); - expect( - calls.some((args) => - args.join(" ").includes("nohup python3 -u /tmp/dns-proxy.py '10.43.0.10' '10.200.0.1'"), - ), - ).toBe(true); - expect(log).toHaveBeenCalledWith(" DNS verification: 4 passed, 0 failed"); - }); - - it("falls back to the CoreDNS pod endpoint when the kube-dns service IP is unavailable", () => { - const calls: string[][] = []; - const runDocker = vi.fn((args: string[]) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok(""); - if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); - if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box-abc\n"); - if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); - if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); - if (cmd.includes("cat /tmp/dns-proxy.log")) - return ok("dns-proxy: 10.200.0.1:53 -> 10.42.0.15:53 pid=12345\n"); - if (cmd.includes("python3 -c")) return ok("ok"); - if (cmd.includes("ls /run/netns/")) return ok("sandbox-ns\n"); - if (cmd.includes("test -x")) return ok(); - if (cmd.includes("cat /etc/resolv.conf")) return ok("nameserver 10.200.0.1\n"); - if (cmd.includes("getent hosts github.com")) return ok("140.82.112.4 github.com\n"); - return ok(); - }); - - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - log: vi.fn(), - runDocker, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(0); - expect(result.dnsUpstream).toBe("10.42.0.15"); - expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); - expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(true); - }); - - it("rejects unsafe DNS upstreams before launching the forwarder", () => { - const calls: string[][] = []; - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - runDocker: (args) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok("bad;rm\n"); - return ok(); - }, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(1); - expect(result.message).toContain("contains invalid characters"); - expect(calls.some((args) => args.join(" ").includes("dns-proxy.py"))).toBe(false); - }); -}); diff --git a/src/lib/actions/dns/index.ts b/src/lib/actions/dns/index.ts index 931c085ac35..bc71ca5198d 100644 --- a/src/lib/actions/dns/index.ts +++ b/src/lib/actions/dns/index.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,22 +9,12 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { buildCoreDnsPatchJson, + type ContainerRuntime, dockerHostRuntime, isSafeDnsUpstream, resolveCoreDnsUpstream, selectOpenshellClusterContainer, - type ContainerRuntime, } from "../../domain/dns/coredns"; -import { - buildDnsProxyPython, - buildDnsReadyProbePython, - buildResolvConf, - DEFAULT_DNS_UPSTREAM, - isSafeDnsAddress, - parseVethGateway, - selectSandboxNamespace, - selectSandboxPod, -} from "../../domain/dns/setup-proxy"; export type CommandResult = Pick, "stderr" | "stdout" | "status">; @@ -44,27 +34,6 @@ export interface FixCoreDnsOptions { gatewayName?: string; } -export interface SetupDnsProxyOptions { - gatewayName: string; - sandboxName: string; -} - -export interface SetupDnsProxyDeps extends FixCoreDnsDeps { - sleep?: (ms: number) => void; -} - -export interface SetupDnsProxyResult { - cluster?: string; - dnsUpstream?: string; - exitCode: number; - message?: string; - pod?: string; - sandboxNamespace?: string; - verificationFail?: number; - verificationPass?: number; - vethGateway?: string; -} - export interface FixCoreDnsResult { cluster?: string; exitCode: number; @@ -158,28 +127,6 @@ function defaultRunDocker( }; } -function sleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function kctl( - runDocker: (args: string[], options?: { env?: NodeJS.ProcessEnv }) => CommandResult, - cluster: string, - args: string[], - env: NodeJS.ProcessEnv, -): CommandResult { - if (args[0] === "exec") { - return runDocker(["exec", cluster, "kubectl", "exec", "-c", "agent", ...args.slice(1)], { - env, - }); - } - return runDocker(["exec", cluster, "kubectl", ...args], { env }); -} - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - function getColimaVmResolvConf(deps: FixCoreDnsDeps, env: NodeJS.ProcessEnv): string { const commandExists = deps.commandExists ?? defaultCommandExists; if (!commandExists("colima")) return ""; @@ -317,408 +264,3 @@ export function runFixCoreDns( log("Done. DNS should resolve in ~10 seconds."); return { cluster, exitCode: 0, runtime: detected.runtime, upstreamDns }; } - -export function runSetupDnsProxy( - options: SetupDnsProxyOptions, - deps: SetupDnsProxyDeps = {}, -): SetupDnsProxyResult { - const env = { ...process.env, ...(deps.env ?? {}) }; - const log = deps.log ?? console.log; - const runDocker = deps.runDocker ?? defaultRunDocker; - const sleep = deps.sleep ?? sleepSync; - const detected = detectDockerHost(env, deps); - const dockerEnv = detected.dockerHost ? { ...env, DOCKER_HOST: detected.dockerHost } : env; - - const clustersOutput = commandOutput( - runDocker(["ps", "--filter", "name=openshell-cluster", "--format", "{{.Names}}"], { - env: dockerEnv, - }), - ); - const cluster = selectOpenshellClusterContainer(options.gatewayName, clustersOutput); - if (!cluster) { - const message = options.gatewayName - ? `WARNING: Could not find gateway container for '${options.gatewayName}'. DNS proxy not installed.` - : "WARNING: Could not find any openshell cluster container. DNS proxy not installed."; - log(message); - return { exitCode: 1, message }; - } - - let dnsUpstream = commandOutput( - kctl( - runDocker, - cluster, - ["get", "service", "kube-dns", "-n", "kube-system", "-o", "jsonpath={.spec.clusterIP}"], - dockerEnv, - ), - ).trim(); - if (!dnsUpstream || dnsUpstream.toLowerCase() === "none") { - log("WARNING: Could not discover kube-dns service IP. Falling back to CoreDNS pod IP."); - dnsUpstream = commandOutput( - kctl( - runDocker, - cluster, - [ - "get", - "endpoints", - "kube-dns", - "-n", - "kube-system", - "-o", - "jsonpath={.subsets[0].addresses[0].ip}", - ], - dockerEnv, - ), - ).trim(); - } - if (!dnsUpstream) { - log("WARNING: Could not discover CoreDNS service or pod IP. Falling back to 8.8.8.8."); - log("WARNING: k8s-internal names (inference.local routing) will NOT work."); - dnsUpstream = DEFAULT_DNS_UPSTREAM; - } - if (!isSafeDnsAddress(dnsUpstream)) { - return { - cluster, - dnsUpstream, - exitCode: 1, - message: `ERROR: DNS upstream '${dnsUpstream}' contains invalid characters.`, - }; - } - - const podsOutput = commandOutput( - kctl(runDocker, cluster, ["get", "pods", "-n", "openshell", "-o", "name"], dockerEnv), - ); - const pod = selectSandboxPod(options.sandboxName, podsOutput); - if (!pod) { - const message = `WARNING: Could not find pod for sandbox '${options.sandboxName}'. DNS proxy not installed.`; - log(message); - return { cluster, dnsUpstream, exitCode: 1, message }; - } - - const vethGateway = parseVethGateway( - commandOutput( - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - "ip addr show | grep 'inet 10\\.200\\.0\\.' | awk '{print $2}' | cut -d/ -f1", - ], - dockerEnv, - ), - ), - ); - if (!isSafeDnsAddress(vethGateway)) { - return { - cluster, - dnsUpstream, - exitCode: 1, - pod, - message: `ERROR: VETH gateway '${vethGateway}' contains invalid characters.`, - }; - } - - log(`Setting up DNS proxy in pod '${pod}' (${vethGateway}:53 -> ${dnsUpstream})...`); - - const proxyWriter = `cat > /tmp/dns-proxy.py << 'DNSPROXY'\n${buildDnsProxyPython()}DNSPROXY`; - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "sh", "-c", proxyWriter], - dockerEnv, - ); - - const oldPid = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.pid"], - dockerEnv, - ), - ).trim(); - if (oldPid) { - kctl(runDocker, cluster, ["exec", "-n", "openshell", pod, "--", "kill", oldPid], dockerEnv); - sleep(1000); - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - `nohup python3 -u /tmp/dns-proxy.py ${shellSingleQuote(dnsUpstream)} ${shellSingleQuote(vethGateway)} > /tmp/dns-proxy.log 2>&1 &`, - ], - dockerEnv, - ); - - let dnsReady = false; - for (let attempt = 0; attempt < 10; attempt += 1) { - const probe = kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "python3", - "-c", - buildDnsReadyProbePython(vethGateway), - ], - dockerEnv, - ); - if (probe.stdout.includes("ok")) { - dnsReady = true; - break; - } - sleep(1000); - } - if (!dnsReady) log("WARNING: DNS forwarder not responding after 10s — verification may fail"); - - const sandboxNamespace = selectSandboxNamespace( - commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "sh", "-c", "ls /run/netns/ 2>/dev/null"], - dockerEnv, - ), - ), - ); - - let iptablesBin = ""; - if (!sandboxNamespace) { - log("WARNING: Could not find sandbox network namespace. DNS may not work."); - } else { - for (const candidate of ["iptables", "/sbin/iptables", "/usr/sbin/iptables"]) { - const test = kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - `test -x "$(command -v ${candidate} 2>/dev/null || echo ${candidate})"`, - ], - dockerEnv, - ); - if (test.status === 0) { - iptablesBin = candidate; - break; - } - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - "[ -f /tmp/resolv.conf.orig ] || cp /etc/resolv.conf /tmp/resolv.conf.orig", - ], - dockerEnv, - ); - - if (iptablesBin) { - const iptablesPrefix = [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - iptablesBin, - ]; - const iptablesRule = ["-p", "udp", "-d", vethGateway, "--dport", "53", "-j", "ACCEPT"]; - const check = kctl( - runDocker, - cluster, - [...iptablesPrefix, "-C", "OUTPUT", ...iptablesRule], - dockerEnv, - ); - if (check.status !== 0) { - kctl( - runDocker, - cluster, - [...iptablesPrefix, "-I", "OUTPUT", "1", ...iptablesRule], - dockerEnv, - ); - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - `printf ${shellSingleQuote(buildResolvConf(vethGateway))} > /etc/resolv.conf`, - ], - dockerEnv, - ); - } else { - log("WARNING: iptables not found in pod (checked PATH, /sbin, /usr/sbin)."); - log("WARNING: Cannot add UDP DNS exception. Sandbox DNS resolution will not work."); - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - "[ -f /tmp/resolv.conf.orig ] && cp /tmp/resolv.conf.orig /etc/resolv.conf", - ], - dockerEnv, - ); - } - } - - let verificationPass = 0; - let verificationFail = 0; - const pid = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.pid"], - dockerEnv, - ), - ).trim(); - const dnsLog = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.log"], - dockerEnv, - ), - ).trim(); - if (pid && dnsLog.includes("dns-proxy:")) { - log(` [PASS] DNS forwarder running (pid=${pid}): ${dnsLog}`); - verificationPass += 1; - } else { - log(` [FAIL] DNS forwarder not running. PID=${pid || "none"} Log: ${dnsLog || "empty"}`); - verificationFail += 1; - } - - const sbExec = (args: string[]) => - sandboxNamespace - ? kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "ip", "netns", "exec", sandboxNamespace, ...args], - dockerEnv, - ) - : null; - - if (sandboxNamespace) { - const resolv = commandOutput( - sbExec(["cat", "/etc/resolv.conf"]) ?? { status: 1, stdout: "", stderr: "" }, - ); - if (resolv.includes(`nameserver ${vethGateway}`)) { - log(` [PASS] resolv.conf -> nameserver ${vethGateway}`); - verificationPass += 1; - } else { - log(` [FAIL] resolv.conf does not point to ${vethGateway}: ${resolv}`); - verificationFail += 1; - } - - const iptablesCheck = sbExec([ - iptablesBin || "iptables", - "-C", - "OUTPUT", - "-p", - "udp", - "-d", - vethGateway, - "--dport", - "53", - "-j", - "ACCEPT", - ]); - if (iptablesCheck?.status === 0) { - log(` [PASS] iptables: UDP ${vethGateway}:53 ACCEPT rule present`); - verificationPass += 1; - } else { - log(" [FAIL] iptables: UDP DNS ACCEPT rule missing"); - verificationFail += 1; - } - - let dnsResult = ""; - for (let attempt = 1; attempt <= 3; attempt += 1) { - dnsResult = commandOutput( - sbExec(["getent", "hosts", "github.com"]) ?? { status: 1, stdout: "", stderr: "" }, - ).trim(); - if (dnsResult) break; - if (attempt < 3) sleep(2000); - } - if (dnsResult) { - log(` [PASS] getent hosts github.com -> ${dnsResult}`); - verificationPass += 1; - } else { - log(" [FAIL] getent hosts github.com returned empty after 3 attempts (DNS not resolving)"); - verificationFail += 1; - } - } else { - log(" [SKIP] Sandbox namespace not found; cannot verify resolv.conf, iptables, or DNS"); - } - - log(` DNS verification: ${verificationPass} passed, ${verificationFail} failed`); - if (verificationFail > 0) - log( - "WARNING: DNS setup incomplete. Sandbox DNS resolution may not work. See issue #626, #557.", - ); - - return { - cluster, - dnsUpstream, - exitCode: 0, - pod, - sandboxNamespace: sandboxNamespace ?? undefined, - verificationFail, - verificationPass, - vethGateway, - }; -} diff --git a/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts index e5744b0d1f4..57d93e06d73 100644 --- a/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts +++ b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts @@ -54,7 +54,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalled(); expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( @@ -85,7 +84,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -114,7 +112,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", diff --git a/src/lib/actions/sandbox/connect-flow.test.ts b/src/lib/actions/sandbox/connect-flow.test.ts index d159758bf8e..00982b5dbac 100644 --- a/src/lib/actions/sandbox/connect-flow.test.ts +++ b/src/lib/actions/sandbox/connect-flow.test.ts @@ -310,7 +310,6 @@ describe("connectSandbox flow", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -349,7 +348,6 @@ describe("connectSandbox flow", () => { "inference.local/v1/models", ); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -388,7 +386,6 @@ describe("connectSandbox flow", () => { "inference.local/v1/models", ); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -424,7 +421,6 @@ describe("connectSandbox flow", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", diff --git a/src/lib/actions/sandbox/connect-route-containment.test.ts b/src/lib/actions/sandbox/connect-route-containment.test.ts index a95b9545e13..348c4ac8a31 100644 --- a/src/lib/actions/sandbox/connect-route-containment.test.ts +++ b/src/lib/actions/sandbox/connect-route-containment.test.ts @@ -44,14 +44,10 @@ describe("connect route containment", () => { }); const probe = vi.fn(() => ({ healthy: false, broken: true, detail: "BROKEN 503" })); const applyVmDnsMonkeypatch = vi.fn(() => ({ ok: false })); - const reapplyVmInferenceRoute = vi.fn(() => null); - const repairLegacyDnsProxy = vi.fn(() => ({ exitCode: 0 })); const deps: SandboxInferenceRouteRepairDeps = { probe, shouldApplyVmDnsMonkeypatch: vi.fn(() => false), applyVmDnsMonkeypatch, - reapplyVmInferenceRoute, - repairLegacyDnsProxy, assertRouteCompatible, }; const sandbox: SandboxEntry = { @@ -70,8 +66,6 @@ describe("connect route containment", () => { expect(assertRouteCompatible).toHaveBeenCalledWith("vm-box", sandbox); expect(probe).not.toHaveBeenCalled(); expect(applyVmDnsMonkeypatch).not.toHaveBeenCalled(); - expect(reapplyVmInferenceRoute).not.toHaveBeenCalled(); - expect(repairLegacyDnsProxy).not.toHaveBeenCalled(); }); it("exits before connect-time route writes when another sandbox conflicts (#6315)", async () => { @@ -109,7 +103,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -208,7 +201,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.errorSpy).toHaveBeenCalledWith( expect.stringContaining("changed OpenShell gateways while waiting"), ); @@ -237,7 +229,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", expect.any(Array), @@ -290,7 +281,6 @@ describe("connect route containment", () => { expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -379,7 +369,7 @@ describe("connect route containment", () => { const inferenceWrites = harness.runOpenshellSpy.mock.calls .map((call) => call[0]) .filter((args) => Array.isArray(args) && args[0] === "inference" && args[1] === "set"); - expect(inferenceWrites).toHaveLength(3); + expect(inferenceWrites).toHaveLength(2); for (const args of inferenceWrites) { expect(args).toEqual([ "inference", @@ -396,6 +386,5 @@ describe("connect route containment", () => { expect([...inferenceReads, ...inferenceWrites]).not.toContainEqual( expect.arrayContaining(["-g", "nemoclaw"]), ); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts index 5c03518db13..8fd7a9b2040 100644 --- a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts +++ b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts @@ -124,7 +124,6 @@ describe("connectSandbox route lifecycle", () => { "alpha", expect.objectContaining({ openshellDriver: "vm" }), ); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); const routeProbeCalls = harness.captureOpenshellSpy.mock.calls.filter((call) => JSON.stringify(call[0]).includes("inference.local/v1/models"), @@ -215,7 +214,6 @@ describe("connectSandbox route lifecycle", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); - expect(harness.runSetupDnsProxySpy).toHaveBeenCalledOnce(); expect(harness.runOpenshellSpy).toHaveBeenCalledOnce(); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( [ diff --git a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts index f50a8e24ea6..2417de055d8 100644 --- a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts @@ -59,32 +59,20 @@ function sandbox(overrides: Partial = {}): SandboxEntry { } function makeRepairDeps(probes: SandboxInferenceRouteProbe[]) { - const calls = { - legacyRepairs: [] as Array<{ sandboxName: string; quiet: boolean }>, - reapplications: [] as string[], - }; const queue = [...probes]; const deps: SandboxInferenceRouteRepairDeps = { probe: vi.fn(() => queue.shift() ?? broken()), shouldApplyVmDnsMonkeypatch: vi.fn(() => false), applyVmDnsMonkeypatch: vi.fn(() => ({ ok: false })), - reapplyVmInferenceRoute: vi.fn((sandboxName) => { - calls.reapplications.push(sandboxName); - return queue.shift() ?? broken(); - }), - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 0 }; - }), log: vi.fn(), error: vi.fn(), }; - return { calls, deps }; + return deps; } describe("sandbox connect inconclusive route repair", () => { it("fails closed without repair when the initial probe is inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([inconclusive()]); + const deps = makeRepairDeps([inconclusive()]); const result = repairSandboxInferenceRouteWithDeps("demo", sandbox(), {}, deps); @@ -93,43 +81,5 @@ describe("sandbox connect inconclusive route repair", () => { repairAttempted: false, detail: "openshell sandbox exec exited with status 7", }); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual([]); - }); - - it("fails closed when non-legacy route reapply remains inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([broken(), inconclusive()]); - - const result = repairSandboxInferenceRouteWithDeps( - "vm-box", - sandbox({ openshellDriver: "vm" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: false, - repairAttempted: true, - detail: "openshell sandbox exec exited with status 7", - }); - expect(calls.reapplications).toEqual(["vm-box"]); - }); - - it("fails closed when a legacy repair probe remains inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([broken(), inconclusive()]); - - const result = repairSandboxInferenceRouteWithDeps( - "legacy-box", - sandbox({ openshellDriver: "kubernetes" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: false, - repairAttempted: true, - detail: "openshell sandbox exec exited with status 7", - }); - expect(calls.legacyRepairs).toEqual([{ sandboxName: "legacy-box", quiet: false }]); }); }); diff --git a/src/lib/actions/sandbox/connect-route-repair.test.ts b/src/lib/actions/sandbox/connect-route-repair.test.ts index c6092b14e35..4f3fd4f6ed9 100644 --- a/src/lib/actions/sandbox/connect-route-repair.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair.test.ts @@ -73,9 +73,7 @@ function makeRepairDeps( const calls = { logs: [] as string[], errors: [] as string[], - legacyRepairs: [] as Array<{ sandboxName: string; quiet: boolean }>, monkeypatches: [] as string[], - reapplications: [] as string[], probeOptions: [] as Array, }; const queue = [...probes]; @@ -89,14 +87,6 @@ function makeRepairDeps( calls.monkeypatches.push(sandboxName); return { ok: false, reason: "not mocked" }; }), - reapplyVmInferenceRoute: vi.fn((sandboxName) => { - calls.reapplications.push(sandboxName); - return queue.shift() ?? broken("missing mocked reapply probe"); - }), - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 0 }; - }), log: (message) => calls.logs.push(message), error: (message) => calls.errors.push(message), ...overrides, @@ -130,41 +120,10 @@ describe("sandbox connect route repair unit flow", () => { repairAttempted: false, detail: "OK 200", }); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual([]); - }); - - it("repairs legacy kubernetes routes through the DNS proxy path", () => { - const { calls, deps } = makeRepairDeps([broken(), healthy()]); - - const result = repairSandboxInferenceRouteWithDeps( - "legacy-box", - sandbox({ openshellDriver: "kubernetes" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: true, - repairAttempted: true, - detail: "OK 200", - }); - expect(calls.legacyRepairs).toEqual([{ sandboxName: "legacy-box", quiet: false }]); - expect(calls.reapplications).toEqual([]); - expect(calls.probeOptions).toEqual([undefined, { attempts: 3, delayMs: 2000 }]); - expect(calls.logs).toContain( - " inference.local is unavailable inside 'legacy-box'. Repairing sandbox DNS proxy...", - ); - expect(calls.logs).toContain(" inference.local route repaired."); }); - it("returns the DNS repair failure detail without route reapply on legacy sandboxes", () => { - const { calls, deps } = makeRepairDeps([broken()], { - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 1, message: "Could not find gateway container" }; - }), - }); + it("delegates broken kubernetes routes to the managed reset", () => { + const { calls, deps } = makeRepairDeps([broken()]); const result = repairSandboxInferenceRouteWithDeps( "legacy-box", @@ -176,27 +135,10 @@ describe("sandbox connect route repair unit flow", () => { expect(result).toEqual({ healthy: false, repairAttempted: true, - detail: "Could not find gateway container", + detail: "BROKEN 503", }); - expect(calls.errors).toContain(" Warning: failed to repair sandbox DNS proxy."); - expect(calls.reapplications).toEqual([]); - }); - - it("uses inference route reapply instead of legacy DNS repair for docker sandboxes", () => { - const { calls, deps } = makeRepairDeps([broken(), healthy()]); - - const result = repairSandboxInferenceRouteWithDeps( - "docker-box", - sandbox({ openshellDriver: "docker" }), - {}, - deps, - ); - - expect(result.healthy).toBe(true); - expect(result.repairAttempted).toBe(true); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual(["docker-box"]); - expect(calls.logs).toContain(" inference.local route repaired."); + expect(calls.probeOptions).toEqual([undefined]); + expect(calls.monkeypatches).toEqual([]); }); it("lets the VM monkeypatch satisfy the route before inference reapply", () => { @@ -217,8 +159,6 @@ describe("sandbox connect route repair unit flow", () => { expect(result.healthy).toBe(true); expect(calls.monkeypatches).toEqual(["vm-box"]); - expect(calls.reapplications).toEqual([]); - expect(calls.legacyRepairs).toEqual([]); expect(calls.probeOptions).toEqual([undefined, { attempts: 3, delayMs: 2000 }]); expect(calls.logs).toContain( " inference.local is unavailable inside 'vm-box'. Applying OpenShell VM DNS monkeypatch...", @@ -228,8 +168,8 @@ describe("sandbox connect route repair unit flow", () => { ); }); - it("falls back to inference reapply when the VM monkeypatch leaves the route broken", () => { - const { calls, deps } = makeRepairDeps([broken(), broken(), healthy()], { + it("delegates to managed reset when the VM monkeypatch leaves the route broken", () => { + const { calls, deps } = makeRepairDeps([broken(), broken()], { shouldApplyVmDnsMonkeypatch: vi.fn(() => true), applyVmDnsMonkeypatch: vi.fn((sandboxName) => { calls.monkeypatches.push(sandboxName); @@ -244,31 +184,14 @@ describe("sandbox connect route repair unit flow", () => { deps, ); - expect(result.healthy).toBe(true); - expect(calls.monkeypatches).toEqual(["vm-box"]); - expect(calls.reapplications).toEqual(["vm-box"]); - expect(calls.errors).toContain( - " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", - ); - }); - - it("reports broken non-legacy routes after inference reapply cannot repair them", () => { - const { calls, deps } = makeRepairDeps([broken(), broken()]); - - const result = repairSandboxInferenceRouteWithDeps( - "vm-box", - sandbox({ openshellDriver: "vm" }), - {}, - deps, - ); - expect(result).toEqual({ healthy: false, repairAttempted: true, detail: "BROKEN 503", }); + expect(calls.monkeypatches).toEqual(["vm-box"]); expect(calls.errors).toContain( - " Warning: inference.local is still unavailable through the OpenShell vm gateway path.", + " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", ); }); }); diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index cd28afc296f..20ba4af4781 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -51,7 +51,6 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "../../state/sandbox-session"; -import { runSetupDnsProxy } from "../dns"; import { runSandboxAutoPairApprovalPass } from "./auto-pair-approval"; import { CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S, @@ -131,14 +130,6 @@ export type SandboxInferenceRouteRepairDeps = { sandboxName: string, sb: SandboxEntry | null, ) => { ok: boolean; reason?: string }; - reapplyVmInferenceRoute: ( - sandboxName: string, - sb: SandboxEntry | null, - ) => SandboxInferenceRouteProbe | null; - repairLegacyDnsProxy: ( - sandboxName: string, - quiet: boolean, - ) => { exitCode: number; message?: string | null }; assertRouteCompatible?: (sandboxName: string, sb: SandboxEntry | null) => void; log?: (message: string) => void; error?: (message: string) => void; @@ -403,33 +394,6 @@ function probeSandboxInferenceRoute( ); } -function shouldUseLegacyDnsProxyRepair(sb: SandboxEntry | null): boolean { - // The legacy repair patches CoreDNS inside an `openshell-cluster-` - // container, which only the k3s/kubernetes gateway runs. The docker driver - // runs the gateway as `nemoclaw-openshell-gateway` with host networking, and - // the vm driver has no cluster container either, so both recover the route via - // `openshell inference set` instead of the cluster CoreDNS patch. Mirrors - // usesGatewayMetadataProbe (snapshot.ts) and the `!== "docker"` guard on the - // snapshot DNS-proxy step. (#3403) - const driver = sb?.openshellDriver; - return driver !== "vm" && driver !== "docker"; -} - -function reapplyVmInferenceRoute( - sandboxName: string, - sb: SandboxEntry | null, - agent: InferenceRouteProbeAgent, - gatewayName: string, -): SandboxInferenceRouteProbe | null { - const inference = sb ? registry.getSandboxEntryInference(sb) : null; - if (inference?.kind !== "configured") return null; - runOpenshell(buildGatewayInferenceSetArgs(gatewayName, inference.provider, inference.model), { - ignoreError: true, - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }); - return probeSandboxInferenceRoute(sandboxName, agent); -} - export function repairSandboxInferenceRouteWithDeps( sandboxName: string, sb: SandboxEntry | null, @@ -449,104 +413,46 @@ export function repairSandboxInferenceRouteWithDeps( if (!initialProbe.broken) { return { healthy: false, repairAttempted: false, detail: initialProbe.detail }; } - if (!shouldUseLegacyDnsProxyRepair(sb)) { - if (deps.shouldApplyVmDnsMonkeypatch(sb)) { - if (!quiet) { - log(""); - log( - ` inference.local is unavailable inside '${sandboxName}'. Applying OpenShell VM DNS monkeypatch...`, - ); - } - const patch = deps.applyVmDnsMonkeypatch(sandboxName, sb); - const patchedProbe = patch.ok - ? deps.probe(sandboxName, { - attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, - delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, - }) - : null; - if (patchedProbe?.healthy) { - if (!quiet) { - log(" inference.local route repaired."); - } - return { - healthy: true, - repairAttempted: true, - detail: patchedProbe.detail, - }; - } - if (!quiet) { - if (!patch.ok && patch.reason) { - error(` Warning: OpenShell VM DNS monkeypatch did not apply: ${patch.reason}`); - } else if (patchedProbe?.broken) { - error( - " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", - ); - } - } - } - + let repairDetail = initialProbe.detail; + if (deps.shouldApplyVmDnsMonkeypatch(sb)) { if (!quiet) { log(""); log( - ` inference.local is unavailable inside '${sandboxName}'. Reapplying OpenShell inference route...`, + ` inference.local is unavailable inside '${sandboxName}'. Applying OpenShell VM DNS monkeypatch...`, ); } - const finalProbe = deps.reapplyVmInferenceRoute(sandboxName, sb); - if (!quiet) { - if (finalProbe?.healthy) { + const patch = deps.applyVmDnsMonkeypatch(sandboxName, sb); + const patchedProbe = patch.ok + ? deps.probe(sandboxName, { + attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, + delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, + }) + : null; + if (patchedProbe) repairDetail = patchedProbe.detail; + if (patchedProbe?.healthy) { + if (!quiet) { log(" inference.local route repaired."); - } else if (finalProbe?.broken) { - error( - ` Warning: inference.local is still unavailable through the OpenShell ${sb?.openshellDriver || "non-legacy"} gateway path.`, - ); } - } - if (!finalProbe) { return { - healthy: false, + healthy: true, repairAttempted: true, - detail: "missing sandbox provider or model", + detail: patchedProbe.detail, }; } - return { - healthy: finalProbe.healthy, - repairAttempted: true, - detail: finalProbe.detail, - }; - } - - if (!quiet) { - log(""); - log(` inference.local is unavailable inside '${sandboxName}'. Repairing sandbox DNS proxy...`); - } - const repair = deps.repairLegacyDnsProxy(sandboxName, quiet); - if (repair.exitCode !== 0) { if (!quiet) { - error(" Warning: failed to repair sandbox DNS proxy."); - if (repair.message) error(` ${repair.message}`); - } - return { - healthy: false, - repairAttempted: true, - detail: repair.message || initialProbe.detail, - }; - } - - const repairedProbe = deps.probe(sandboxName, { - attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, - delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, - }); - if (!quiet) { - if (repairedProbe.healthy) { - log(" inference.local route repaired."); - } else if (repairedProbe.broken) { - error(" Warning: inference.local is still unavailable after DNS proxy repair."); + if (!patch.ok && patch.reason) { + error(` Warning: OpenShell VM DNS monkeypatch did not apply: ${patch.reason}`); + } else if (patchedProbe?.broken) { + error( + " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", + ); + } } } return { - healthy: repairedProbe.healthy, + healthy: false, repairAttempted: true, - detail: repairedProbe.detail, + detail: repairDetail, }; } @@ -566,13 +472,6 @@ function repairSandboxInferenceRouteIfNeeded( probe: (name, options) => probeSandboxInferenceRoute(name, agent, options), shouldApplyVmDnsMonkeypatch, applyVmDnsMonkeypatch: applyOpenShellVmDnsMonkeypatch, - reapplyVmInferenceRoute: (name, sandbox) => - reapplyVmInferenceRoute(name, sandbox, agent, gatewayName), - repairLegacyDnsProxy: (name, isQuiet) => - runSetupDnsProxy( - { gatewayName, sandboxName: name }, - { log: isQuiet ? () => undefined : console.log }, - ), assertRouteCompatible: (name, sandbox) => { if (sandbox) assertSandboxGatewayRouteCompatible(name, sandbox, gatewayName); }, diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 3b437205c3b..aceca3ffb0e 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -43,13 +43,13 @@ import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { getSandboxEntryInference } from "../../state/registry-entry-view"; import * as sandboxState from "../../state/sandbox"; -import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy"; import { DCODE_AGENT_NAME, DCODE_BUSY_PROBE_SCRIPT, DCODE_PROBE_STATE, parseDcodeProbeState, } from "./dcode-activity-probe"; +import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy"; import { buildSandboxExecMarkedCommand, createSandboxExecMarker, @@ -207,16 +207,6 @@ async function autoCreateSandboxFromSource( snapshotExit(1); } - // DNS proxy is only meaningful for the kubernetes driver (matches onboard.ts). - const dnsScript = path.join(ROOT, "scripts", "setup-dns-proxy.sh"); - const srcDriver = (srcEntry as { openshellDriver?: string | null }).openshellDriver; - if (srcDriver === "kubernetes" && fs.existsSync(dnsScript)) { - const srcGatewayName = resolveSandboxGatewayName( - srcEntry as { gatewayName?: string | null; gatewayPort?: number | null }, - ); - run(["bash", dnsScript, srcGatewayName, dstName], { ignoreError: true }); - } - // Register dst in the NemoClaw registry, cloning most fields from src. // Policies are cleared here — the caller replays them from the snapshot // manifest after the restore succeeds and writes them back into this entry. diff --git a/src/lib/domain/dns/setup-proxy.test.ts b/src/lib/domain/dns/setup-proxy.test.ts deleted file mode 100644 index 94575ebf817..00000000000 --- a/src/lib/domain/dns/setup-proxy.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - buildDnsProxyPython, - buildDnsReadyProbePython, - buildResolvConf, - isSafeDnsAddress, - parseVethGateway, - selectSandboxNamespace, - selectSandboxPod, -} from "./setup-proxy.js"; - -describe("DNS setup proxy domain helpers", () => { - it("selects a sandbox pod using fixed-string style matching", () => { - expect(selectSandboxPod("box[1]", "pod/alpha\npod/box[1]-abc\n")).toBe("box[1]-abc"); - expect(selectSandboxPod("missing", "pod/alpha\n")).toBeNull(); - }); - - it("falls back to the default veth gateway when discovery is empty", () => { - expect(parseVethGateway("10.200.0.1\n")).toBe("10.200.0.1"); - expect(parseVethGateway("\n")).toBe("10.200.0.1"); - }); - - it("selects the first sandbox namespace", () => { - expect(selectSandboxNamespace("other\nsandbox-ns\n")).toBe("sandbox-ns"); - expect(selectSandboxNamespace("other\n")).toBeNull(); - }); - - it("builds checked-in DNS proxy payloads and resolv.conf content", () => { - expect(buildDnsProxyPython()).toContain("sock.bind((BIND_IP, 53))"); - expect(buildDnsReadyProbePython("10.200.0.1")).toContain("10.200.0.1"); - expect(buildResolvConf("10.200.0.1")).toBe("nameserver 10.200.0.1\noptions ndots:5\n"); - }); - - it("rejects unsafe DNS address strings", () => { - expect(isSafeDnsAddress("10.43.0.10")).toBe(true); - expect(isSafeDnsAddress("bad;rm")).toBe(false); - }); -}); diff --git a/src/lib/domain/dns/setup-proxy.ts b/src/lib/domain/dns/setup-proxy.ts deleted file mode 100644 index 930c71f7ba5..00000000000 --- a/src/lib/domain/dns/setup-proxy.ts +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export const DEFAULT_DNS_UPSTREAM = "8.8.8.8"; -export const DEFAULT_VETH_GATEWAY = "10.200.0.1"; - -export function buildDnsProxyPython(): string { - return String.raw`import socket, threading, os, sys - -UPSTREAM = (sys.argv[1] if len(sys.argv) > 1 else '8.8.8.8', 53) -BIND_IP = sys.argv[2] if len(sys.argv) > 2 else '0.0.0.0' - -sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind((BIND_IP, 53)) - -with open('/tmp/dns-proxy.pid', 'w') as pf: - pf.write(str(os.getpid())) - -msg = 'dns-proxy: {}:53 -> {}:{} pid={}'.format(BIND_IP, UPSTREAM[0], UPSTREAM[1], os.getpid()) -print(msg, flush=True) -with open('/tmp/dns-proxy.log', 'w') as log: - log.write(msg + '\n') - -def forward(data, addr): - try: - f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - f.settimeout(5) - f.sendto(data, UPSTREAM) - r, _ = f.recvfrom(4096) - sock.sendto(r, addr) - f.close() - except Exception: - pass - -while True: - d, a = sock.recvfrom(4096) - threading.Thread(target=forward, args=(d, a), daemon=True).start() -`; -} - -export function buildDnsReadyProbePython(vethGateway: string): string { - return String.raw` -import socket, sys -s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -s.settimeout(1) -try: - s.sendto(b'\x00\x1e\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01', - (${JSON.stringify(vethGateway)}, 53)) - data, _ = s.recvfrom(4096) - sys.stdout.write('ok' if data else '') -except Exception: - pass -`; -} - -// Kubernetes pod names append generated suffixes to the requested sandbox name. -// Keep substring matching so names such as "box[1]" match "pod/box[1]-abc", -// then strip the leading "pod/" prefix before returning the pod name. -export function selectSandboxPod(sandboxName: string, podsOutput: string): string | null { - for (const line of podsOutput.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.includes(sandboxName)) continue; - return trimmed.replace(/^pod\//, ""); - } - return null; -} - -export function parseVethGateway(output: string): string { - const trimmed = output.trim(); - return trimmed || DEFAULT_VETH_GATEWAY; -} - -export function selectSandboxNamespace(output: string): string | null { - return ( - output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.includes("sandbox")) ?? null - ); -} - -export function buildResolvConf(vethGateway: string): string { - return `nameserver ${vethGateway}\noptions ndots:5\n`; -} - -export function isSafeDnsAddress(value: string): boolean { - return /^[a-zA-Z0-9.:_-]+$/.test(value); -} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 78715bad448..3284da9315d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -155,7 +155,7 @@ const os = require("os"); const path = require("path"); const pRetry = require("p-retry"); const runner: typeof import("./runner") = require("./runner"); -const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, runFile, validateName } = runner; +const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); const { applyExtraProviderReconciliation, @@ -2916,15 +2916,6 @@ async function createSandboxWithBaseImageResolution( ); restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization - // DNS proxy — run a forwarder in the sandbox pod so the isolated - // sandbox namespace can resolve hostnames (fixes #626). - if (sandboxRuntimeFields.openshellDriver === "kubernetes") { - console.log(" Setting up sandbox DNS proxy..."); - runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { - ignoreError: true, - }); - } - require("./onboard/vm-dns-monkeypatch").applyOnboardVmDnsMonkeypatch( sandboxName, sandboxRuntimeFields, diff --git a/src/lib/platform.ts b/src/lib/platform.ts index a8a5ddb51ed..cf34b0f80c5 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -88,7 +88,7 @@ function containerCanReachHostLoopback( function shouldPatchCoredns(runtime: ContainerRuntime, opts: WslDetectionOptions = {}): boolean { // CoreDNS patching is needed for Colima and Podman (both use custom network bridges). - // On WSL2, the host DNS is not routable from k3s pods — skip and let setup-dns-proxy.sh handle it. + // OpenShell owns sandbox DNS on WSL2; this host-side CoreDNS patch is not applicable there. if (isWsl(opts)) return false; return runtime === "colima" || runtime === "podman"; } diff --git a/test/dns-proxy.test.ts b/test/dns-proxy.test.ts index d992136cf03..15dfa43bdfa 100644 --- a/test/dns-proxy.test.ts +++ b/test/dns-proxy.test.ts @@ -1,111 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; -const SETUP_DNS_PROXY = path.join(import.meta.dirname, "..", "scripts", "setup-dns-proxy.sh"); -const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); const FIX_COREDNS = path.join(import.meta.dirname, "..", "scripts", "fix-coredns.sh"); -describe("setup-dns-proxy.sh", () => { - it("exists and is executable", () => { - const stat = fs.statSync(SETUP_DNS_PROXY); - expect(stat.isFile()).toBe(true); - expect(stat.mode & 0o100).toBeTruthy(); - }); - - it("sources runtime.sh successfully", () => { - const result = spawnSync("bash", ["-c", `source "${RUNTIME_SH}"; echo ok`], { - encoding: /** @type {const} */ "utf-8", - env: { ...process.env }, - }); - expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe("ok"); - }); - - it("exits with usage when no sandbox name provided", () => { - const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw"], { - encoding: /** @type {const} */ "utf-8", - env: { ...process.env }, - }); - expect(result.status).not.toBe(0); - expect(result.stderr + result.stdout).toMatch(/Usage:/i); - }); - - it("configures DNS proxy through kubectl and verifies sandbox DNS end to end", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-proxy-")); - const fakeBin = path.join(tmp, "bin"); - const dockerLog = path.join(tmp, "docker.log"); - fs.mkdirSync(fakeBin); - fs.writeFileSync( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> ${JSON.stringify(dockerLog)} -if [ "\${1:-}" = "ps" ]; then - echo "openshell-cluster-nemoclaw" - exit 0 -fi -if [ "\${1:-}" != "exec" ]; then - exit 1 -fi -shift # cluster name -shift # kubectl -cmd="$*" -case "$cmd" in - *"get service kube-dns"*) echo "10.43.0.10"; exit 0 ;; - *"get endpoints kube-dns"*) echo "10.42.0.15"; exit 0 ;; - *"get pods -n openshell -o name"*) echo "pod/box[1]-abc"; exit 0 ;; - *"ip addr show"*) echo "10.200.0.1"; exit 0 ;; - *"cat /tmp/dns-proxy.pid"*) echo "12345"; exit 0 ;; - *"cat /tmp/dns-proxy.log"*) echo "dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345"; exit 0 ;; - *"python3 -c"*) echo "ok"; exit 0 ;; - *"ls /run/netns/"*) echo "sandbox-ns"; exit 0 ;; - *"test -x"*) [[ "$cmd" == *"/usr/sbin/iptables"* ]] && exit 0 || exit 1 ;; - *"cat /etc/resolv.conf"*) echo "nameserver 10.200.0.1"; exit 0 ;; - *"getent hosts github.com"*) echo "140.82.112.4 github.com"; exit 0 ;; -esac -exit 0 -`, - { mode: 0o755 }, - ); - - try { - const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw", "box[1]"], { - encoding: "utf-8", - env: { - ...process.env, - DOCKER_HOST: "unix:///tmp/fake-docker.sock", - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - timeout: 15000, - }); - const output = `${result.stdout}${result.stderr}`; - expect(result.status).toBe(0); - expect(output).toContain("Setting up DNS proxy in pod 'box[1]-abc'"); - expect(output).toContain("DNS verification: 4 passed, 0 failed"); - - const calls = fs.readFileSync(dockerLog, "utf-8"); - expect(calls).toContain("get service kube-dns"); - expect(calls).not.toContain("get endpoints kube-dns"); - expect(calls).toContain("kube-system"); - expect(calls).toContain("nohup python3 -u /tmp/dns-proxy.py"); - expect(calls).toContain("10.43.0.10"); - expect(calls).toContain("10.200.0.1"); - expect(calls).toContain("/usr/sbin/iptables"); - expect(calls).toContain("--dport 53"); - expect(calls).toContain("cp /etc/resolv.conf /tmp/resolv.conf.orig"); - expect(calls).not.toContain("nsenter"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - describe("fix-coredns.sh", () => { it("exists and is executable", () => { const stat = fs.statSync(FIX_COREDNS); diff --git a/test/internal-cli.test.ts b/test/internal-cli.test.ts index 8027c5d8685..de7cb7c6455 100644 --- a/test/internal-cli.test.ts +++ b/test/internal-cli.test.ts @@ -18,18 +18,6 @@ describe("internal oclif namespace", () => { expect(result.stdout).toContain("nemoclaw internal dns fix-coredns [gateway-name]"); }); - it("exposes setup-proxy as an oclif-routed internal subcommand", () => { - const result = spawnSync(process.execPath, [CLI, "internal", "dns", "setup-proxy", "--help"], { - encoding: "utf-8", - }); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("Internal: configure sandbox DNS proxy"); - expect(result.stdout).toContain( - "nemoclaw internal dns setup-proxy ", - ); - }); - it("exposes uninstall plan commands through oclif routing", () => { const result = spawnSync( process.execPath, diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts index c9533feaa86..ac2a2d8e24d 100644 --- a/test/sandbox-connect-inference/helpers.ts +++ b/test/sandbox-connect-inference/helpers.ts @@ -377,50 +377,6 @@ if ( process.exit(0); } -if (cmd.includes("get service kube-dns")) { - process.stdout.write("10.43.0.10"); - process.exit(0); -} -if (cmd.includes("get endpoints kube-dns")) { - process.stdout.write("10.42.0.15"); - process.exit(0); -} -if (cmd.includes("get pods -n openshell -o name")) { - process.stdout.write("pod/${sandboxName}-abc\\n"); - process.exit(0); -} -if (cmd.includes("ip addr show")) { - process.stdout.write("10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.pid")) { - process.stdout.write("12345\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.log")) { - process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); - process.exit(0); -} -if (cmd.includes("python3 -c")) { - process.stdout.write("ok"); - process.exit(0); -} -if (cmd.includes("ls /run/netns/")) { - process.stdout.write("sandbox-ns\\n"); - process.exit(0); -} -if (cmd.includes("test -x")) { - process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); -} -if (cmd.includes("cat /etc/resolv.conf")) { - process.stdout.write("nameserver 10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("getent hosts github.com")) { - process.stdout.write("140.82.112.4 github.com\\n"); - process.exit(0); -} - process.exit(0); `, ); diff --git a/test/sandbox-connect-inference/route-swap-repair.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts index e38dc87eaea..212ffd16ac6 100644 --- a/test/sandbox-connect-inference/route-swap-repair.test.ts +++ b/test/sandbox-connect-inference/route-swap-repair.test.ts @@ -67,7 +67,6 @@ describe("sandbox connect inference route swap (#1248)", () => { 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', - 'BROKEN 503 {"error":"upstream unavailable"}', "OK 200", ], }, @@ -136,7 +135,6 @@ describe("sandbox connect inference route swap (#1248)", () => { 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', - 'BROKEN 503 {"error":"upstream unavailable"}', "OK 200", ], writeOllamaProxyState: false, diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 741c7683df3..067afc205a5 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -1,15 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "child_process"; // Verify sandbox names stay validated and out of raw shell command strings. -import fs from "fs"; -import os from "os"; import path from "path"; import { describe, expect, it } from "vitest"; -import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; - describe("sandboxName command hardening in onboard.js", () => { it("re-validates sandboxName at the createSandbox boundary", async () => { const onboardModule = await import("../src/lib/onboard.js"); @@ -28,125 +23,6 @@ describe("sandboxName command hardening in onboard.js", () => { ).rejects.toThrow(/Invalid sandbox name/); }); - it("runs setup-dns-proxy.sh through the argv helper instead of bash -c interpolation", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-argv-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-dns-argv.cjs"); - const sourceModule = (...segments: string[]) => - JSON.stringify(path.join(repoRoot, "src", "lib", ...segments)); - const onboardPath = sourceModule("onboard.ts"); - const runnerPath = sourceModule("runner.ts"); - const registryPath = sourceModule("state", "registry.ts"); - const preflightPath = sourceModule("onboard", "preflight.ts"); - const credentialsPath = sourceModule("credentials", "store.ts"); - const streamPath = sourceModule("sandbox", "create-stream.ts"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); - fs.writeFileSync( - scriptPath, - String.raw` -const runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const sandboxCreateStream = require(${streamPath}); -for (const key of Object.keys(process.env)) { - if (/^(NEMOCLAW|OPENSHELL)_/.test(key) || key === "CHAT_UI_URL") { - delete process.env[key]; - } -} -process.env.NEMOCLAW_OPENSHELL_BIN = ${JSON.stringify(path.join(fakeBin, "openshell"))}; -const commands = []; -const asText = (command) => Array.isArray(command) ? command.join(" ") : String(command); -runner.run = (command, opts = {}) => { - commands.push({ type: "run", command: asText(command), env: opts.env || null }); - return { status: 0 }; -}; -runner.runFile = (file, args = [], opts = {}) => { - commands.push({ type: "runFile", file, args, command: asText([file, ...args]), env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - const text = asText(command); - if (text.includes("sandbox get my-assistant")) return ""; - if (text.includes("sandbox list")) return "my-assistant Ready"; - if (text.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; - if (text.includes("sandbox exec") && text.includes("http://localhost:") && text.includes("/health")) return "200"; - if (text === "uname -r") return "6.8.0"; - return ""; -}; -registry.getSandbox = () => null; -registry.getDisabledChannels = () => []; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -registry.updateSandbox = () => true; -preflight.checkPortAvailable = async () => ({ ok: true }); -credentials.prompt = async () => ""; -sandboxCreateStream.streamSandboxCreate = async () => ({ - status: 0, - output: "Built image openshell/sandbox-from:123\nCreated sandbox: my-assistant", - sawProgress: true, -}); -const { createSandbox } = require(${onboardPath}); -(async () => { -try { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - process.env.NEMOCLAW_NON_INTERACTIVE = "1"; - process.env.NEMOCLAW_HEALTH_POLL_COUNT = "1"; - Object.defineProperty(process, "platform", { value: "darwin" }); - Object.defineProperty(process, "arch", { value: "x64" }); - const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); - console.log(JSON.stringify({ sandboxName, commands })); -} catch (error) { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); -} -})(); -`, - ); - - try { - const result = spawnSync( - process.execPath, - [ - "--require", - path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), - scriptPath, - ], - { - cwd: repoRoot, - encoding: "utf-8", - env: { HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, - timeout: 30_000, - }, - ); - expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); - const payloadLine = result.stdout - .trim() - .split("\n") - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - expect(payloadLine).toBeTruthy(); - const payload = JSON.parse(payloadLine!); - const dnsCommand = payload.commands.find( - (entry: { type: string; args: string[] }) => - entry.type === "runFile" && entry.args[0]?.endsWith("setup-dns-proxy.sh"), - ); - expect(dnsCommand).toBeTruthy(); - expect(dnsCommand.file).toBe("bash"); - expect(dnsCommand.args).toEqual([ - expect.stringMatching(/setup-dns-proxy\.sh$/), - "nemoclaw", - "my-assistant", - ]); - expect(dnsCommand.command).not.toContain("bash -c"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("builds openshell argv with an explicit openshellBinary override", async () => { const onboardModule = await import("../src/lib/onboard.js"); const onboard = onboardModule as unknown as { diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index 0b405f0453a..3c37c5be8d9 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -37,7 +37,6 @@ export type ConnectHarness = { resolveAgentConfigSpy: MockInstance; runAutoPairSpy: MockInstance; runOpenshellSpy: MockInstance; - runSetupDnsProxySpy: MockInstance; spawnSyncSpy: MockInstance; withGatewayRouteMutationLockSpy: MockInstance; writeSandboxConfigSpy: MockInstance; @@ -100,7 +99,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const runtime = requireDist("../../src/lib/adapters/openshell/runtime.js"); const resolve = requireDist("../../src/lib/adapters/openshell/resolve.js"); const agentRuntime = requireDist("../../src/lib/agent/runtime.js"); - const dns = requireDist("../../src/lib/actions/dns/index.js"); const gatewayState = requireDist("../../src/lib/actions/sandbox/gateway-state.js"); const processRecovery = requireDist("../../src/lib/actions/sandbox/process-recovery.js"); const autoPairApproval = requireDist("../../src/lib/actions/sandbox/auto-pair-approval.js"); @@ -160,7 +158,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne (async (_gatewayName: string, operation: () => Promise | unknown) => await operation())) as never, ); - const runSetupDnsProxySpy = vi.spyOn(dns, "runSetupDnsProxy").mockReturnValue({ exitCode: 0 }); const applyVmDnsMonkeypatchSpy = vi .spyOn(vmDnsMonkeypatch, "applyOpenShellVmDnsMonkeypatch") .mockReturnValue({ attempted: true, changed: true, ok: true, status: "applied" }); @@ -262,7 +259,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne resolveAgentConfigSpy, runAutoPairSpy, runOpenshellSpy, - runSetupDnsProxySpy, spawnSyncSpy, withGatewayRouteMutationLockSpy, writeSandboxConfigSpy, From dfa196805c55132e04d874b33d89185421adc02d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 13 Jul 2026 17:45:27 -0700 Subject: [PATCH 2/5] test(cli): track retired DNS proxy command Signed-off-by: Aaron Erickson --- test/internal-commands-docs.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/internal-commands-docs.test.ts b/test/internal-commands-docs.test.ts index d3512298f1b..a5ab04b0609 100644 --- a/test/internal-commands-docs.test.ts +++ b/test/internal-commands-docs.test.ts @@ -24,9 +24,8 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; - -import { getRegisteredOclifCommandsMetadata } from "../src/lib/cli/oclif-metadata"; import { renderAgentVariantPage } from "../scripts/sync-agent-variant-docs"; +import { getRegisteredOclifCommandsMetadata } from "../src/lib/cli/oclif-metadata"; const repoRoot = path.resolve(import.meta.dirname, ".."); @@ -86,7 +85,7 @@ describe("internal command documentation (#3782)", () => { it("registers the hidden internal command family", () => { // Guards against the manifest silently losing the family (which would make // the documentation assertions below vacuously pass). - expect(internalIds.length).toBeGreaterThanOrEqual(9); + expect(internalIds.length).toBeGreaterThanOrEqual(8); }); it.each(references)("documents every hidden internal command in $name", ({ text, binary }) => { From a9c985c6e87248e20e86419a06063c0ae201cf7c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 13 Jul 2026 17:54:03 -0700 Subject: [PATCH 3/5] test(openshell): align DNS transport inventory Signed-off-by: Aaron Erickson --- scripts/checks/legacy-sandbox-transports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/checks/legacy-sandbox-transports.ts b/scripts/checks/legacy-sandbox-transports.ts index 454213ccdae..d0c8e43e7e7 100644 --- a/scripts/checks/legacy-sandbox-transports.ts +++ b/scripts/checks/legacy-sandbox-transports.ts @@ -32,7 +32,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".. type ReviewedSiteTuple = readonly [string, LegacySandboxTransportKind, number]; const REVIEWED_SITE_TUPLES = [ - ["src/lib/actions/dns/index.ts", "docker-exec-command", 4], + ["src/lib/actions/dns/index.ts", "docker-exec-command", 2], ["src/lib/actions/sandbox/host-aliases.ts", "docker-exec-command", 1], ["src/lib/actions/sandbox/process-recovery.ts", "privileged-sandbox-exec", 2], ["src/lib/resources-cmd.ts", "docker-exec-command", 1], From bc3229e205993729af67cf1b735b09a2233d23ed Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 14 Jul 2026 23:57:26 -0700 Subject: [PATCH 4/5] refactor(openshell): finish DNS proxy removal Signed-off-by: Aaron Erickson --- docs/reference/commands.mdx | 3 +- scripts/checks/legacy-sandbox-transports.ts | 2 +- scripts/setup-dns-proxy.sh | 24 - src/commands/internal/dns/setup-proxy.ts | 30 -- src/lib/actions/README.md | 2 +- src/lib/actions/dns/index.test.ts | 110 +---- src/lib/actions/dns/index.ts | 464 +----------------- .../connect-flow-dcode-probe-preamble.test.ts | 3 - src/lib/actions/sandbox/connect-flow.test.ts | 10 +- .../sandbox/connect-route-containment.test.ts | 13 +- .../sandbox/connect-route-lifecycle.test.ts | 6 +- .../connect-route-repair-inconclusive.test.ts | 54 +- .../sandbox/connect-route-repair.test.ts | 95 +--- src/lib/actions/sandbox/connect.ts | 151 +----- src/lib/actions/sandbox/snapshot.ts | 10 - src/lib/domain/dns/setup-proxy.test.ts | 42 -- src/lib/domain/dns/setup-proxy.ts | 89 ---- src/lib/onboard.ts | 11 +- src/lib/platform.ts | 2 +- test/dns-proxy.test.ts | 103 +--- test/internal-cli.test.ts | 12 - test/internal-commands-docs.test.ts | 5 +- test/sandbox-connect-inference/helpers.ts | 44 -- .../route-swap-repair.test.ts | 2 - test/shellquote-sandbox.test.ts | 124 ----- test/support/connect-flow-test-harness.ts | 4 - 26 files changed, 56 insertions(+), 1359 deletions(-) delete mode 100755 scripts/setup-dns-proxy.sh delete mode 100644 src/commands/internal/dns/setup-proxy.ts delete mode 100644 src/lib/domain/dns/setup-proxy.test.ts delete mode 100644 src/lib/domain/dns/setup-proxy.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 4cd68310674..1181c466dfe 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3121,7 +3121,6 @@ For contributor guidance on how these command files are structured, refer to | `$$nemoclaw internal uninstall plan` | `uninstall.sh` / `$$nemoclaw uninstall` | Build a deterministic uninstall plan without applying it. | | `$$nemoclaw internal uninstall run-plan` | `uninstall.sh` / `$$nemoclaw uninstall` | Remove host-side NemoClaw resources from a previously built plan. | | `$$nemoclaw internal uninstall classify-shim` | `uninstall.sh` / `$$nemoclaw uninstall` | Classify whether a shim path is safe for the uninstaller to remove. | -| `$$nemoclaw internal dns setup-proxy` | onboarding / sandbox setup | Configure the DNS forwarder bridge inside a sandbox pod. | | `$$nemoclaw internal dns fix-coredns` | onboarding / sandbox setup | Patch CoreDNS to use a non-loopback upstream resolver. | | `$$nemoclaw internal dev npm-link-or-shim` | `scripts/npm-link-or-shim.sh` (development) | Run `npm link`, falling back to a user-local NemoClaw development shim. | @@ -3564,7 +3563,7 @@ The following flags change defaults for commands that manage existing sandboxes. |----------|--------|--------| | `NEMOCLAW_CLEANUP_GATEWAY` | `1`, `true`, or `yes` to enable; `0`, `false`, or `no` to disable | Overrides the platform default (macOS unattended: cleanup; Linux/Windows: preserve) for whether `$$nemoclaw destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. | | `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` | Exact JSON array of sandbox names | Confirms to the installer that the exact listed set of pre-fingerprint OpenClaw or Hermes sandboxes used NemoClaw-managed images, allowing recovery onto the current managed image. The normalized names must exactly match the installer's printed array. Set it only after verifying every named sandbox. Recorded custom-image evidence remains blocked. | -| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `$$nemoclaw connect` and `$$nemoclaw connect --probe-only`. Use only as a troubleshooting escape hatch. | +| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips automatic route recovery for stale `inference.local` routes during `$$nemoclaw connect` and `$$nemoclaw connect --probe-only`. Use only as a troubleshooting escape hatch. | | `NEMOCLAW_DISABLE_SUPERVISOR_RELAUNCH` | `1` to enable | Skips the automatic trusted container recreation during `$$nemoclaw recover` when two managed scans find no supervisor while PID 1 remains stable. Use only as a troubleshooting escape hatch; recovery then falls back to the rebuild or re-onboard guidance. | | `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | Allows advanced immutable-config verification to trust the current on-disk bytes for older or partial content baselines. Use only after you have rebuilt or manually inspected the sandbox state and accepted that the baseline is operator-approved. | | `NEMOCLAW_SHIELDS_SETTLE_MS` | milliseconds (default `750`, clamped to `0` to `10000`) | Settle window NemoClaw waits after re-applying a config lockdown (during shields auto-restore and `$$nemoclaw shields up` drift remediation) before re-confirming the lock still holds. Detects when an in-sandbox reconciler changes config file permissions after lockdown and re-applies the lock; if NemoClaw cannot re-confirm the lock within the retry budget, shields stay down. This narrows the window in which a reconciler can revert permissions rather than eliminating it. The best-effort `chattr +i` immutable bit remains the only fully durable lock. Raise it on hosts where the gateway settles slowly. | diff --git a/scripts/checks/legacy-sandbox-transports.ts b/scripts/checks/legacy-sandbox-transports.ts index 7150bf8efd2..de1c94cdec0 100644 --- a/scripts/checks/legacy-sandbox-transports.ts +++ b/scripts/checks/legacy-sandbox-transports.ts @@ -35,7 +35,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".. type ReviewedSiteTuple = readonly [string, LegacySandboxTransportKind, number]; const REVIEWED_SITE_TUPLES = [ - ["src/lib/actions/dns/index.ts", "docker-exec-command", 4], + ["src/lib/actions/dns/index.ts", "docker-exec-command", 2], ["src/lib/actions/sandbox/host-aliases.ts", "docker-exec-command", 1], ["src/lib/actions/sandbox/sessions/passthrough.ts", "grpc-cli-read-only-fallback", 1], ["src/lib/actions/sandbox/process-recovery.ts", "privileged-sandbox-exec", 2], diff --git a/scripts/setup-dns-proxy.sh b/scripts/setup-dns-proxy.sh deleted file mode 100755 index ff8ba4a7e25..00000000000 --- a/scripts/setup-dns-proxy.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Compatibility wrapper for the TypeScript sandbox DNS proxy setup. -# -# Usage: ./scripts/setup-dns-proxy.sh [gateway-name] - -set -euo pipefail - -if [ "$#" -lt 2 ]; then - echo "Usage: $0 [gateway-name] " - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CLI_JS="${NEMOCLAW_CLI_JS:-$REPO_ROOT/dist/nemoclaw.js}" - -if [ -f "$CLI_JS" ]; then - exec node "$CLI_JS" internal dns setup-proxy "$@" -fi - -exec nemoclaw internal dns setup-proxy "$@" diff --git a/src/commands/internal/dns/setup-proxy.ts b/src/commands/internal/dns/setup-proxy.ts deleted file mode 100644 index 5dceb256bf2..00000000000 --- a/src/commands/internal/dns/setup-proxy.ts +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Args } from "@oclif/core"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; - -import { runSetupDnsProxy } from "../../../lib/actions/dns"; - -export default class InternalDnsSetupProxyCommand extends NemoClawCommand { - static hidden = true; - static strict = true; - static summary = "Internal: configure sandbox DNS proxy"; - static description = "Configure the DNS forwarder bridge inside a sandbox pod."; - static usage = ["internal dns setup-proxy "]; - static examples = ["<%= config.bin %> internal dns setup-proxy nemoclaw my-sandbox"]; - static args = { - gatewayName: Args.string({ description: "OpenShell gateway name", required: true }), - sandboxName: Args.string({ description: "Sandbox name", required: true }), - }; - static flags = {}; - - public async run(): Promise { - const { args } = await this.parse(InternalDnsSetupProxyCommand); - const result = runSetupDnsProxy({ - gatewayName: args.gatewayName, - sandboxName: args.sandboxName, - }); - this.applyExitResult(result); - } -} diff --git a/src/lib/actions/README.md b/src/lib/actions/README.md index 47b21f625fb..95deb25c824 100644 --- a/src/lib/actions/README.md +++ b/src/lib/actions/README.md @@ -15,7 +15,7 @@ src/lib/actions//.test.ts Examples: ```text -dns/index.ts # internal dns fix-coredns/setup-proxy orchestration +dns/index.ts # internal dns fix-coredns orchestration dev/npm-link-or-shim.ts # prepare-time dev shim orchestration installer/plan.ts # deterministic installer planning uninstall/plan.ts # host uninstall planning diff --git a/src/lib/actions/dns/index.test.ts b/src/lib/actions/dns/index.test.ts index 3a290de9e11..3d0da920d63 100644 --- a/src/lib/actions/dns/index.test.ts +++ b/src/lib/actions/dns/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CommandResult } from "./index"; -import { runFixCoreDns, runSetupDnsProxy } from "./index.js"; +import { runFixCoreDns } from "./index.js"; function ok(stdout = ""): CommandResult { return { status: 0, stdout, stderr: "" }; @@ -157,111 +157,3 @@ describe("runFixCoreDns", () => { expect(result.message).toBe("patch failed"); }); }); - -describe("runSetupDnsProxy", () => { - it("configures the DNS proxy through kubectl-in-docker argv calls", () => { - const calls: string[][] = []; - const log = vi.fn(); - const runDocker = vi.fn((args: string[]) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok("10.43.0.10"); - if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); - if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box[1]-abc\n"); - if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); - if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); - if (cmd.includes("cat /tmp/dns-proxy.log")) - return ok("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\n"); - if (cmd.includes("python3 -c")) return ok("ok"); - if (cmd.includes("ls /run/netns/")) return ok("sandbox-ns\n"); - if (cmd.includes("test -x")) return ok(); - if (cmd.includes("cat /etc/resolv.conf")) return ok("nameserver 10.200.0.1\n"); - if (cmd.includes("getent hosts github.com")) return ok("140.82.112.4 github.com\n"); - return ok(); - }); - - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box[1]" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - log, - runDocker, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(0); - expect(result.dnsUpstream).toBe("10.43.0.10"); - expect(result.pod).toBe("box[1]-abc"); - expect(result.verificationPass).toBe(4); - expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); - expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(false); - expect(calls.some((args) => args.includes("box[1]-abc"))).toBe(true); - expect( - calls.some((args) => - args.join(" ").includes("nohup python3 -u /tmp/dns-proxy.py '10.43.0.10' '10.200.0.1'"), - ), - ).toBe(true); - expect(log).toHaveBeenCalledWith(" DNS verification: 4 passed, 0 failed"); - }); - - it("falls back to the CoreDNS pod endpoint when the kube-dns service IP is unavailable", () => { - const calls: string[][] = []; - const runDocker = vi.fn((args: string[]) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok(""); - if (cmd.includes("get endpoints kube-dns")) return ok("10.42.0.15"); - if (cmd.includes("get pods -n openshell -o name")) return ok("pod/box-abc\n"); - if (cmd.includes("ip addr show")) return ok("10.200.0.1\n"); - if (cmd.includes("cat /tmp/dns-proxy.pid")) return ok("12345\n"); - if (cmd.includes("cat /tmp/dns-proxy.log")) - return ok("dns-proxy: 10.200.0.1:53 -> 10.42.0.15:53 pid=12345\n"); - if (cmd.includes("python3 -c")) return ok("ok"); - if (cmd.includes("ls /run/netns/")) return ok("sandbox-ns\n"); - if (cmd.includes("test -x")) return ok(); - if (cmd.includes("cat /etc/resolv.conf")) return ok("nameserver 10.200.0.1\n"); - if (cmd.includes("getent hosts github.com")) return ok("140.82.112.4 github.com\n"); - return ok(); - }); - - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - log: vi.fn(), - runDocker, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(0); - expect(result.dnsUpstream).toBe("10.42.0.15"); - expect(calls.some((args) => args.join(" ").includes("get service kube-dns"))).toBe(true); - expect(calls.some((args) => args.join(" ").includes("get endpoints kube-dns"))).toBe(true); - }); - - it("rejects unsafe DNS upstreams before launching the forwarder", () => { - const calls: string[][] = []; - const result = runSetupDnsProxy( - { gatewayName: "nemoclaw", sandboxName: "box" }, - { - env: { DOCKER_HOST: "unix:///tmp/fake-docker.sock" }, - runDocker: (args) => { - calls.push(args); - const cmd = args.join(" "); - if (args[0] === "ps") return ok("openshell-cluster-nemoclaw\n"); - if (cmd.includes("get service kube-dns")) return ok("bad;rm\n"); - return ok(); - }, - sleep: vi.fn(), - }, - ); - - expect(result.exitCode).toBe(1); - expect(result.message).toContain("contains invalid characters"); - expect(calls.some((args) => args.join(" ").includes("dns-proxy.py"))).toBe(false); - }); -}); diff --git a/src/lib/actions/dns/index.ts b/src/lib/actions/dns/index.ts index 931c085ac35..929552e0b1f 100644 --- a/src/lib/actions/dns/index.ts +++ b/src/lib/actions/dns/index.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,22 +9,12 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { buildCoreDnsPatchJson, + type ContainerRuntime, dockerHostRuntime, isSafeDnsUpstream, resolveCoreDnsUpstream, selectOpenshellClusterContainer, - type ContainerRuntime, } from "../../domain/dns/coredns"; -import { - buildDnsProxyPython, - buildDnsReadyProbePython, - buildResolvConf, - DEFAULT_DNS_UPSTREAM, - isSafeDnsAddress, - parseVethGateway, - selectSandboxNamespace, - selectSandboxPod, -} from "../../domain/dns/setup-proxy"; export type CommandResult = Pick, "stderr" | "stdout" | "status">; @@ -44,27 +34,6 @@ export interface FixCoreDnsOptions { gatewayName?: string; } -export interface SetupDnsProxyOptions { - gatewayName: string; - sandboxName: string; -} - -export interface SetupDnsProxyDeps extends FixCoreDnsDeps { - sleep?: (ms: number) => void; -} - -export interface SetupDnsProxyResult { - cluster?: string; - dnsUpstream?: string; - exitCode: number; - message?: string; - pod?: string; - sandboxNamespace?: string; - verificationFail?: number; - verificationPass?: number; - vethGateway?: string; -} - export interface FixCoreDnsResult { cluster?: string; exitCode: number; @@ -158,28 +127,6 @@ function defaultRunDocker( }; } -function sleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function kctl( - runDocker: (args: string[], options?: { env?: NodeJS.ProcessEnv }) => CommandResult, - cluster: string, - args: string[], - env: NodeJS.ProcessEnv, -): CommandResult { - if (args[0] === "exec") { - return runDocker(["exec", cluster, "kubectl", "exec", "-c", "agent", ...args.slice(1)], { - env, - }); - } - return runDocker(["exec", cluster, "kubectl", ...args], { env }); -} - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - function getColimaVmResolvConf(deps: FixCoreDnsDeps, env: NodeJS.ProcessEnv): string { const commandExists = deps.commandExists ?? defaultCommandExists; if (!commandExists("colima")) return ""; @@ -202,7 +149,7 @@ export function runFixCoreDns( const env = { ...process.env, ...(deps.env ?? {}) }; const log = deps.log ?? console.log; const readFile = deps.readFile ?? ((filePath: string) => fs.readFileSync(filePath, "utf-8")); - const runDocker = deps.runDocker ?? defaultRunDocker; + const { runDocker = defaultRunDocker } = deps; const detected = detectDockerHost(env, deps); if (!detected.dockerHost || (detected.runtime !== "colima" && detected.runtime !== "podman")) { @@ -317,408 +264,3 @@ export function runFixCoreDns( log("Done. DNS should resolve in ~10 seconds."); return { cluster, exitCode: 0, runtime: detected.runtime, upstreamDns }; } - -export function runSetupDnsProxy( - options: SetupDnsProxyOptions, - deps: SetupDnsProxyDeps = {}, -): SetupDnsProxyResult { - const env = { ...process.env, ...(deps.env ?? {}) }; - const log = deps.log ?? console.log; - const runDocker = deps.runDocker ?? defaultRunDocker; - const sleep = deps.sleep ?? sleepSync; - const detected = detectDockerHost(env, deps); - const dockerEnv = detected.dockerHost ? { ...env, DOCKER_HOST: detected.dockerHost } : env; - - const clustersOutput = commandOutput( - runDocker(["ps", "--filter", "name=openshell-cluster", "--format", "{{.Names}}"], { - env: dockerEnv, - }), - ); - const cluster = selectOpenshellClusterContainer(options.gatewayName, clustersOutput); - if (!cluster) { - const message = options.gatewayName - ? `WARNING: Could not find gateway container for '${options.gatewayName}'. DNS proxy not installed.` - : "WARNING: Could not find any openshell cluster container. DNS proxy not installed."; - log(message); - return { exitCode: 1, message }; - } - - let dnsUpstream = commandOutput( - kctl( - runDocker, - cluster, - ["get", "service", "kube-dns", "-n", "kube-system", "-o", "jsonpath={.spec.clusterIP}"], - dockerEnv, - ), - ).trim(); - if (!dnsUpstream || dnsUpstream.toLowerCase() === "none") { - log("WARNING: Could not discover kube-dns service IP. Falling back to CoreDNS pod IP."); - dnsUpstream = commandOutput( - kctl( - runDocker, - cluster, - [ - "get", - "endpoints", - "kube-dns", - "-n", - "kube-system", - "-o", - "jsonpath={.subsets[0].addresses[0].ip}", - ], - dockerEnv, - ), - ).trim(); - } - if (!dnsUpstream) { - log("WARNING: Could not discover CoreDNS service or pod IP. Falling back to 8.8.8.8."); - log("WARNING: k8s-internal names (inference.local routing) will NOT work."); - dnsUpstream = DEFAULT_DNS_UPSTREAM; - } - if (!isSafeDnsAddress(dnsUpstream)) { - return { - cluster, - dnsUpstream, - exitCode: 1, - message: `ERROR: DNS upstream '${dnsUpstream}' contains invalid characters.`, - }; - } - - const podsOutput = commandOutput( - kctl(runDocker, cluster, ["get", "pods", "-n", "openshell", "-o", "name"], dockerEnv), - ); - const pod = selectSandboxPod(options.sandboxName, podsOutput); - if (!pod) { - const message = `WARNING: Could not find pod for sandbox '${options.sandboxName}'. DNS proxy not installed.`; - log(message); - return { cluster, dnsUpstream, exitCode: 1, message }; - } - - const vethGateway = parseVethGateway( - commandOutput( - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - "ip addr show | grep 'inet 10\\.200\\.0\\.' | awk '{print $2}' | cut -d/ -f1", - ], - dockerEnv, - ), - ), - ); - if (!isSafeDnsAddress(vethGateway)) { - return { - cluster, - dnsUpstream, - exitCode: 1, - pod, - message: `ERROR: VETH gateway '${vethGateway}' contains invalid characters.`, - }; - } - - log(`Setting up DNS proxy in pod '${pod}' (${vethGateway}:53 -> ${dnsUpstream})...`); - - const proxyWriter = `cat > /tmp/dns-proxy.py << 'DNSPROXY'\n${buildDnsProxyPython()}DNSPROXY`; - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "sh", "-c", proxyWriter], - dockerEnv, - ); - - const oldPid = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.pid"], - dockerEnv, - ), - ).trim(); - if (oldPid) { - kctl(runDocker, cluster, ["exec", "-n", "openshell", pod, "--", "kill", oldPid], dockerEnv); - sleep(1000); - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - `nohup python3 -u /tmp/dns-proxy.py ${shellSingleQuote(dnsUpstream)} ${shellSingleQuote(vethGateway)} > /tmp/dns-proxy.log 2>&1 &`, - ], - dockerEnv, - ); - - let dnsReady = false; - for (let attempt = 0; attempt < 10; attempt += 1) { - const probe = kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "python3", - "-c", - buildDnsReadyProbePython(vethGateway), - ], - dockerEnv, - ); - if (probe.stdout.includes("ok")) { - dnsReady = true; - break; - } - sleep(1000); - } - if (!dnsReady) log("WARNING: DNS forwarder not responding after 10s — verification may fail"); - - const sandboxNamespace = selectSandboxNamespace( - commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "sh", "-c", "ls /run/netns/ 2>/dev/null"], - dockerEnv, - ), - ), - ); - - let iptablesBin = ""; - if (!sandboxNamespace) { - log("WARNING: Could not find sandbox network namespace. DNS may not work."); - } else { - for (const candidate of ["iptables", "/sbin/iptables", "/usr/sbin/iptables"]) { - const test = kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "sh", - "-c", - `test -x "$(command -v ${candidate} 2>/dev/null || echo ${candidate})"`, - ], - dockerEnv, - ); - if (test.status === 0) { - iptablesBin = candidate; - break; - } - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - "[ -f /tmp/resolv.conf.orig ] || cp /etc/resolv.conf /tmp/resolv.conf.orig", - ], - dockerEnv, - ); - - if (iptablesBin) { - const iptablesPrefix = [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - iptablesBin, - ]; - const iptablesRule = ["-p", "udp", "-d", vethGateway, "--dport", "53", "-j", "ACCEPT"]; - const check = kctl( - runDocker, - cluster, - [...iptablesPrefix, "-C", "OUTPUT", ...iptablesRule], - dockerEnv, - ); - if (check.status !== 0) { - kctl( - runDocker, - cluster, - [...iptablesPrefix, "-I", "OUTPUT", "1", ...iptablesRule], - dockerEnv, - ); - } - - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - `printf ${shellSingleQuote(buildResolvConf(vethGateway))} > /etc/resolv.conf`, - ], - dockerEnv, - ); - } else { - log("WARNING: iptables not found in pod (checked PATH, /sbin, /usr/sbin)."); - log("WARNING: Cannot add UDP DNS exception. Sandbox DNS resolution will not work."); - kctl( - runDocker, - cluster, - [ - "exec", - "-n", - "openshell", - pod, - "--", - "ip", - "netns", - "exec", - sandboxNamespace, - "sh", - "-c", - "[ -f /tmp/resolv.conf.orig ] && cp /tmp/resolv.conf.orig /etc/resolv.conf", - ], - dockerEnv, - ); - } - } - - let verificationPass = 0; - let verificationFail = 0; - const pid = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.pid"], - dockerEnv, - ), - ).trim(); - const dnsLog = commandOutput( - kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "cat", "/tmp/dns-proxy.log"], - dockerEnv, - ), - ).trim(); - if (pid && dnsLog.includes("dns-proxy:")) { - log(` [PASS] DNS forwarder running (pid=${pid}): ${dnsLog}`); - verificationPass += 1; - } else { - log(` [FAIL] DNS forwarder not running. PID=${pid || "none"} Log: ${dnsLog || "empty"}`); - verificationFail += 1; - } - - const sbExec = (args: string[]) => - sandboxNamespace - ? kctl( - runDocker, - cluster, - ["exec", "-n", "openshell", pod, "--", "ip", "netns", "exec", sandboxNamespace, ...args], - dockerEnv, - ) - : null; - - if (sandboxNamespace) { - const resolv = commandOutput( - sbExec(["cat", "/etc/resolv.conf"]) ?? { status: 1, stdout: "", stderr: "" }, - ); - if (resolv.includes(`nameserver ${vethGateway}`)) { - log(` [PASS] resolv.conf -> nameserver ${vethGateway}`); - verificationPass += 1; - } else { - log(` [FAIL] resolv.conf does not point to ${vethGateway}: ${resolv}`); - verificationFail += 1; - } - - const iptablesCheck = sbExec([ - iptablesBin || "iptables", - "-C", - "OUTPUT", - "-p", - "udp", - "-d", - vethGateway, - "--dport", - "53", - "-j", - "ACCEPT", - ]); - if (iptablesCheck?.status === 0) { - log(` [PASS] iptables: UDP ${vethGateway}:53 ACCEPT rule present`); - verificationPass += 1; - } else { - log(" [FAIL] iptables: UDP DNS ACCEPT rule missing"); - verificationFail += 1; - } - - let dnsResult = ""; - for (let attempt = 1; attempt <= 3; attempt += 1) { - dnsResult = commandOutput( - sbExec(["getent", "hosts", "github.com"]) ?? { status: 1, stdout: "", stderr: "" }, - ).trim(); - if (dnsResult) break; - if (attempt < 3) sleep(2000); - } - if (dnsResult) { - log(` [PASS] getent hosts github.com -> ${dnsResult}`); - verificationPass += 1; - } else { - log(" [FAIL] getent hosts github.com returned empty after 3 attempts (DNS not resolving)"); - verificationFail += 1; - } - } else { - log(" [SKIP] Sandbox namespace not found; cannot verify resolv.conf, iptables, or DNS"); - } - - log(` DNS verification: ${verificationPass} passed, ${verificationFail} failed`); - if (verificationFail > 0) - log( - "WARNING: DNS setup incomplete. Sandbox DNS resolution may not work. See issue #626, #557.", - ); - - return { - cluster, - dnsUpstream, - exitCode: 0, - pod, - sandboxNamespace: sandboxNamespace ?? undefined, - verificationFail, - verificationPass, - vethGateway, - }; -} diff --git a/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts index e5744b0d1f4..57d93e06d73 100644 --- a/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts +++ b/src/lib/actions/sandbox/connect-flow-dcode-probe-preamble.test.ts @@ -54,7 +54,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalled(); expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( @@ -85,7 +84,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -114,7 +112,6 @@ describe("connectSandbox DCode probe preamble boundary", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", diff --git a/src/lib/actions/sandbox/connect-flow.test.ts b/src/lib/actions/sandbox/connect-flow.test.ts index d159758bf8e..14baa880d98 100644 --- a/src/lib/actions/sandbox/connect-flow.test.ts +++ b/src/lib/actions/sandbox/connect-flow.test.ts @@ -310,7 +310,6 @@ describe("connectSandbox flow", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -349,7 +348,6 @@ describe("connectSandbox flow", () => { "inference.local/v1/models", ); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -359,7 +357,7 @@ describe("connectSandbox flow", () => { const errorOutput = harness.errorSpy.mock.calls.flat().join("\n"); expect(errorOutput).toContain("did not return a trusted result"); expect(errorOutput).toContain("Last probe: sandbox exec transport failed"); - expect(errorOutput).not.toContain("after DNS and route repair"); + expect(errorOutput).not.toContain("after route repair"); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -388,7 +386,6 @@ describe("connectSandbox flow", () => { "inference.local/v1/models", ); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -398,7 +395,7 @@ describe("connectSandbox flow", () => { const errorOutput = harness.errorSpy.mock.calls.flat().join("\n"); expect(errorOutput).toContain("did not return a trusted result"); expect(errorOutput).toContain("openshell sandbox exec exited with status 1"); - expect(errorOutput).not.toContain("after DNS and route repair"); + expect(errorOutput).not.toContain("after route repair"); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -424,7 +421,6 @@ describe("connectSandbox flow", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", @@ -434,7 +430,7 @@ describe("connectSandbox flow", () => { const errorOutput = harness.errorSpy.mock.calls.flat().join("\n"); expect(errorOutput).toContain("did not return a trusted result"); expect(errorOutput).toContain("OpenShell CA bundle missing or unreadable"); - expect(errorOutput).not.toContain("after DNS and route repair"); + expect(errorOutput).not.toContain("after route repair"); expect(exitSpy).toHaveBeenCalledWith(1); }); diff --git a/src/lib/actions/sandbox/connect-route-containment.test.ts b/src/lib/actions/sandbox/connect-route-containment.test.ts index a95b9545e13..348c4ac8a31 100644 --- a/src/lib/actions/sandbox/connect-route-containment.test.ts +++ b/src/lib/actions/sandbox/connect-route-containment.test.ts @@ -44,14 +44,10 @@ describe("connect route containment", () => { }); const probe = vi.fn(() => ({ healthy: false, broken: true, detail: "BROKEN 503" })); const applyVmDnsMonkeypatch = vi.fn(() => ({ ok: false })); - const reapplyVmInferenceRoute = vi.fn(() => null); - const repairLegacyDnsProxy = vi.fn(() => ({ exitCode: 0 })); const deps: SandboxInferenceRouteRepairDeps = { probe, shouldApplyVmDnsMonkeypatch: vi.fn(() => false), applyVmDnsMonkeypatch, - reapplyVmInferenceRoute, - repairLegacyDnsProxy, assertRouteCompatible, }; const sandbox: SandboxEntry = { @@ -70,8 +66,6 @@ describe("connect route containment", () => { expect(assertRouteCompatible).toHaveBeenCalledWith("vm-box", sandbox); expect(probe).not.toHaveBeenCalled(); expect(applyVmDnsMonkeypatch).not.toHaveBeenCalled(); - expect(reapplyVmInferenceRoute).not.toHaveBeenCalled(); - expect(repairLegacyDnsProxy).not.toHaveBeenCalled(); }); it("exits before connect-time route writes when another sandbox conflicts (#6315)", async () => { @@ -109,7 +103,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -208,7 +201,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.errorSpy).toHaveBeenCalledWith( expect.stringContaining("changed OpenShell gateways while waiting"), ); @@ -237,7 +229,6 @@ describe("connect route containment", () => { expect(harness.captureOpenshellSpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", expect.any(Array), @@ -290,7 +281,6 @@ describe("connect route containment", () => { expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); expect(harness.applyVmDnsMonkeypatchSpy).not.toHaveBeenCalled(); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.spawnSyncSpy).not.toHaveBeenCalledWith( "openshell", ["sandbox", "connect", "alpha"], @@ -379,7 +369,7 @@ describe("connect route containment", () => { const inferenceWrites = harness.runOpenshellSpy.mock.calls .map((call) => call[0]) .filter((args) => Array.isArray(args) && args[0] === "inference" && args[1] === "set"); - expect(inferenceWrites).toHaveLength(3); + expect(inferenceWrites).toHaveLength(2); for (const args of inferenceWrites) { expect(args).toEqual([ "inference", @@ -396,6 +386,5 @@ describe("connect route containment", () => { expect([...inferenceReads, ...inferenceWrites]).not.toContainEqual( expect.arrayContaining(["-g", "nemoclaw"]), ); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts index 5c03518db13..812348d84ad 100644 --- a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts +++ b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts @@ -124,7 +124,6 @@ describe("connectSandbox route lifecycle", () => { "alpha", expect.objectContaining({ openshellDriver: "vm" }), ); - expect(harness.runSetupDnsProxySpy).not.toHaveBeenCalled(); expect(harness.runOpenshellSpy).not.toHaveBeenCalled(); const routeProbeCalls = harness.captureOpenshellSpy.mock.calls.filter((call) => JSON.stringify(call[0]).includes("inference.local/v1/models"), @@ -191,7 +190,7 @@ describe("connectSandbox route lifecycle", () => { expect(errorOutput).toContain("failed to verify or repair inference route"); expect(errorOutput).toContain("did not return a trusted result"); expect(errorOutput).toContain("route is not known healthy"); - expect(errorOutput).not.toContain("after DNS and route repair"); + expect(errorOutput).not.toContain("after route repair"); expect(errorOutput).not.toContain("route is known to be broken"); expect(harness.captureOpenshellSpy).toHaveBeenCalledWith( ["inference", "get", "-g", "nemoclaw"], @@ -215,7 +214,6 @@ describe("connectSandbox route lifecycle", () => { await expect(harness.connectSandbox("alpha")).rejects.toThrow("process.exit(1)"); - expect(harness.runSetupDnsProxySpy).toHaveBeenCalledOnce(); expect(harness.runOpenshellSpy).toHaveBeenCalledOnce(); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( [ @@ -237,7 +235,7 @@ describe("connectSandbox route lifecycle", () => { expect.any(Object), ); const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); - expect(errorOutput).toContain("inference.local is still unavailable"); + expect(errorOutput).toContain("inside 'alpha' after route repair."); expect(errorOutput).toContain( "Connect is stopping because the sandbox inference route is known to be broken", ); diff --git a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts index 54f4cddbf20..d762223e7bf 100644 --- a/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair-inconclusive.test.ts @@ -60,32 +60,20 @@ function sandbox(overrides: Partial = {}): SandboxEntry { } function makeRepairDeps(probes: SandboxInferenceRouteProbe[]) { - const calls = { - legacyRepairs: [] as Array<{ sandboxName: string; quiet: boolean }>, - reapplications: [] as string[], - }; const queue = [...probes]; const deps: SandboxInferenceRouteRepairDeps = { probe: vi.fn(() => queue.shift() ?? broken()), shouldApplyVmDnsMonkeypatch: vi.fn(() => false), applyVmDnsMonkeypatch: vi.fn(() => ({ ok: false })), - reapplyVmInferenceRoute: vi.fn((sandboxName) => { - calls.reapplications.push(sandboxName); - return queue.shift() ?? broken(); - }), - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 0 }; - }), log: vi.fn(), error: vi.fn(), }; - return { calls, deps }; + return deps; } describe("sandbox connect inconclusive route repair", () => { it("fails closed without repair when the initial probe is inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([inconclusive()]); + const deps = makeRepairDeps([inconclusive()]); const result = repairSandboxInferenceRouteWithDeps("demo", sandbox(), {}, deps); @@ -94,43 +82,5 @@ describe("sandbox connect inconclusive route repair", () => { repairAttempted: false, detail: "openshell sandbox exec exited with status 7", }); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual([]); - }); - - it("fails closed when non-legacy route reapply remains inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([broken(), inconclusive()]); - - const result = repairSandboxInferenceRouteWithDeps( - "vm-box", - sandbox({ openshellDriver: "vm" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: false, - repairAttempted: true, - detail: "openshell sandbox exec exited with status 7", - }); - expect(calls.reapplications).toEqual(["vm-box"]); - }); - - it("fails closed when a legacy repair probe remains inconclusive (#6192)", () => { - const { calls, deps } = makeRepairDeps([broken(), inconclusive()]); - - const result = repairSandboxInferenceRouteWithDeps( - "legacy-box", - sandbox({ openshellDriver: "kubernetes" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: false, - repairAttempted: true, - detail: "openshell sandbox exec exited with status 7", - }); - expect(calls.legacyRepairs).toEqual([{ sandboxName: "legacy-box", quiet: false }]); }); }); diff --git a/src/lib/actions/sandbox/connect-route-repair.test.ts b/src/lib/actions/sandbox/connect-route-repair.test.ts index c2318a49e3e..7cb45b95389 100644 --- a/src/lib/actions/sandbox/connect-route-repair.test.ts +++ b/src/lib/actions/sandbox/connect-route-repair.test.ts @@ -74,9 +74,7 @@ function makeRepairDeps( const calls = { logs: [] as string[], errors: [] as string[], - legacyRepairs: [] as Array<{ sandboxName: string; quiet: boolean }>, monkeypatches: [] as string[], - reapplications: [] as string[], probeOptions: [] as Array, }; const queue = [...probes]; @@ -90,14 +88,6 @@ function makeRepairDeps( calls.monkeypatches.push(sandboxName); return { ok: false, reason: "not mocked" }; }), - reapplyVmInferenceRoute: vi.fn((sandboxName) => { - calls.reapplications.push(sandboxName); - return queue.shift() ?? broken("missing mocked reapply probe"); - }), - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 0 }; - }), log: (message) => calls.logs.push(message), error: (message) => calls.errors.push(message), ...overrides, @@ -131,41 +121,10 @@ describe("sandbox connect route repair unit flow", () => { repairAttempted: false, detail: "OK 200", }); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual([]); - }); - - it("repairs legacy kubernetes routes through the DNS proxy path", () => { - const { calls, deps } = makeRepairDeps([broken(), healthy()]); - - const result = repairSandboxInferenceRouteWithDeps( - "legacy-box", - sandbox({ openshellDriver: "kubernetes" }), - {}, - deps, - ); - - expect(result).toEqual({ - healthy: true, - repairAttempted: true, - detail: "OK 200", - }); - expect(calls.legacyRepairs).toEqual([{ sandboxName: "legacy-box", quiet: false }]); - expect(calls.reapplications).toEqual([]); - expect(calls.probeOptions).toEqual([undefined, { attempts: 3, delayMs: 2000 }]); - expect(calls.logs).toContain( - " inference.local is unavailable inside 'legacy-box'. Repairing sandbox DNS proxy...", - ); - expect(calls.logs).toContain(" inference.local route repaired."); }); - it("returns the DNS repair failure detail without route reapply on legacy sandboxes", () => { - const { calls, deps } = makeRepairDeps([broken()], { - repairLegacyDnsProxy: vi.fn((sandboxName, quiet) => { - calls.legacyRepairs.push({ sandboxName, quiet }); - return { exitCode: 1, message: "Could not find gateway container" }; - }), - }); + it("delegates broken kubernetes routes to the managed reset", () => { + const { calls, deps } = makeRepairDeps([broken()]); const result = repairSandboxInferenceRouteWithDeps( "legacy-box", @@ -177,27 +136,10 @@ describe("sandbox connect route repair unit flow", () => { expect(result).toEqual({ healthy: false, repairAttempted: true, - detail: "Could not find gateway container", + detail: "BROKEN 503", }); - expect(calls.errors).toContain(" Warning: failed to repair sandbox DNS proxy."); - expect(calls.reapplications).toEqual([]); - }); - - it("uses inference route reapply instead of legacy DNS repair for docker sandboxes", () => { - const { calls, deps } = makeRepairDeps([broken(), healthy()]); - - const result = repairSandboxInferenceRouteWithDeps( - "docker-box", - sandbox({ openshellDriver: "docker" }), - {}, - deps, - ); - - expect(result.healthy).toBe(true); - expect(result.repairAttempted).toBe(true); - expect(calls.legacyRepairs).toEqual([]); - expect(calls.reapplications).toEqual(["docker-box"]); - expect(calls.logs).toContain(" inference.local route repaired."); + expect(calls.probeOptions).toEqual([undefined]); + expect(calls.monkeypatches).toEqual([]); }); it("lets the VM monkeypatch satisfy the route before inference reapply", () => { @@ -218,8 +160,6 @@ describe("sandbox connect route repair unit flow", () => { expect(result.healthy).toBe(true); expect(calls.monkeypatches).toEqual(["vm-box"]); - expect(calls.reapplications).toEqual([]); - expect(calls.legacyRepairs).toEqual([]); expect(calls.probeOptions).toEqual([undefined, { attempts: 3, delayMs: 2000 }]); expect(calls.logs).toContain( " inference.local is unavailable inside 'vm-box'. Applying OpenShell VM DNS monkeypatch...", @@ -229,8 +169,8 @@ describe("sandbox connect route repair unit flow", () => { ); }); - it("falls back to inference reapply when the VM monkeypatch leaves the route broken", () => { - const { calls, deps } = makeRepairDeps([broken(), broken(), healthy()], { + it("delegates to managed reset when the VM monkeypatch leaves the route broken", () => { + const { calls, deps } = makeRepairDeps([broken(), broken()], { shouldApplyVmDnsMonkeypatch: vi.fn(() => true), applyVmDnsMonkeypatch: vi.fn((sandboxName) => { calls.monkeypatches.push(sandboxName); @@ -245,31 +185,14 @@ describe("sandbox connect route repair unit flow", () => { deps, ); - expect(result.healthy).toBe(true); - expect(calls.monkeypatches).toEqual(["vm-box"]); - expect(calls.reapplications).toEqual(["vm-box"]); - expect(calls.errors).toContain( - " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", - ); - }); - - it("reports broken non-legacy routes after inference reapply cannot repair them", () => { - const { calls, deps } = makeRepairDeps([broken(), broken()]); - - const result = repairSandboxInferenceRouteWithDeps( - "vm-box", - sandbox({ openshellDriver: "vm" }), - {}, - deps, - ); - expect(result).toEqual({ healthy: false, repairAttempted: true, detail: "BROKEN 503", }); + expect(calls.monkeypatches).toEqual(["vm-box"]); expect(calls.errors).toContain( - " Warning: inference.local is still unavailable through the OpenShell vm gateway path.", + " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", ); }); }); diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index cd28afc296f..b16b6d434d6 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -51,7 +51,6 @@ import { createSystemDeps as createSessionDeps, getActiveSandboxSessions, } from "../../state/sandbox-session"; -import { runSetupDnsProxy } from "../dns"; import { runSandboxAutoPairApprovalPass } from "./auto-pair-approval"; import { CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S, @@ -131,14 +130,6 @@ export type SandboxInferenceRouteRepairDeps = { sandboxName: string, sb: SandboxEntry | null, ) => { ok: boolean; reason?: string }; - reapplyVmInferenceRoute: ( - sandboxName: string, - sb: SandboxEntry | null, - ) => SandboxInferenceRouteProbe | null; - repairLegacyDnsProxy: ( - sandboxName: string, - quiet: boolean, - ) => { exitCode: number; message?: string | null }; assertRouteCompatible?: (sandboxName: string, sb: SandboxEntry | null) => void; log?: (message: string) => void; error?: (message: string) => void; @@ -403,33 +394,6 @@ function probeSandboxInferenceRoute( ); } -function shouldUseLegacyDnsProxyRepair(sb: SandboxEntry | null): boolean { - // The legacy repair patches CoreDNS inside an `openshell-cluster-` - // container, which only the k3s/kubernetes gateway runs. The docker driver - // runs the gateway as `nemoclaw-openshell-gateway` with host networking, and - // the vm driver has no cluster container either, so both recover the route via - // `openshell inference set` instead of the cluster CoreDNS patch. Mirrors - // usesGatewayMetadataProbe (snapshot.ts) and the `!== "docker"` guard on the - // snapshot DNS-proxy step. (#3403) - const driver = sb?.openshellDriver; - return driver !== "vm" && driver !== "docker"; -} - -function reapplyVmInferenceRoute( - sandboxName: string, - sb: SandboxEntry | null, - agent: InferenceRouteProbeAgent, - gatewayName: string, -): SandboxInferenceRouteProbe | null { - const inference = sb ? registry.getSandboxEntryInference(sb) : null; - if (inference?.kind !== "configured") return null; - runOpenshell(buildGatewayInferenceSetArgs(gatewayName, inference.provider, inference.model), { - ignoreError: true, - timeout: OPENSHELL_OPERATION_TIMEOUT_MS, - }); - return probeSandboxInferenceRoute(sandboxName, agent); -} - export function repairSandboxInferenceRouteWithDeps( sandboxName: string, sb: SandboxEntry | null, @@ -449,104 +413,46 @@ export function repairSandboxInferenceRouteWithDeps( if (!initialProbe.broken) { return { healthy: false, repairAttempted: false, detail: initialProbe.detail }; } - if (!shouldUseLegacyDnsProxyRepair(sb)) { - if (deps.shouldApplyVmDnsMonkeypatch(sb)) { - if (!quiet) { - log(""); - log( - ` inference.local is unavailable inside '${sandboxName}'. Applying OpenShell VM DNS monkeypatch...`, - ); - } - const patch = deps.applyVmDnsMonkeypatch(sandboxName, sb); - const patchedProbe = patch.ok - ? deps.probe(sandboxName, { - attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, - delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, - }) - : null; - if (patchedProbe?.healthy) { - if (!quiet) { - log(" inference.local route repaired."); - } - return { - healthy: true, - repairAttempted: true, - detail: patchedProbe.detail, - }; - } - if (!quiet) { - if (!patch.ok && patch.reason) { - error(` Warning: OpenShell VM DNS monkeypatch did not apply: ${patch.reason}`); - } else if (patchedProbe?.broken) { - error( - " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", - ); - } - } - } - + let repairDetail = initialProbe.detail; + if (deps.shouldApplyVmDnsMonkeypatch(sb)) { if (!quiet) { log(""); log( - ` inference.local is unavailable inside '${sandboxName}'. Reapplying OpenShell inference route...`, + ` inference.local is unavailable inside '${sandboxName}'. Applying OpenShell VM DNS monkeypatch...`, ); } - const finalProbe = deps.reapplyVmInferenceRoute(sandboxName, sb); - if (!quiet) { - if (finalProbe?.healthy) { + const patch = deps.applyVmDnsMonkeypatch(sandboxName, sb); + const patchedProbe = patch.ok + ? deps.probe(sandboxName, { + attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, + delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, + }) + : null; + if (patchedProbe) repairDetail = patchedProbe.detail; + if (patchedProbe?.healthy) { + if (!quiet) { log(" inference.local route repaired."); - } else if (finalProbe?.broken) { - error( - ` Warning: inference.local is still unavailable through the OpenShell ${sb?.openshellDriver || "non-legacy"} gateway path.`, - ); } - } - if (!finalProbe) { return { - healthy: false, + healthy: true, repairAttempted: true, - detail: "missing sandbox provider or model", + detail: patchedProbe.detail, }; } - return { - healthy: finalProbe.healthy, - repairAttempted: true, - detail: finalProbe.detail, - }; - } - - if (!quiet) { - log(""); - log(` inference.local is unavailable inside '${sandboxName}'. Repairing sandbox DNS proxy...`); - } - const repair = deps.repairLegacyDnsProxy(sandboxName, quiet); - if (repair.exitCode !== 0) { if (!quiet) { - error(" Warning: failed to repair sandbox DNS proxy."); - if (repair.message) error(` ${repair.message}`); - } - return { - healthy: false, - repairAttempted: true, - detail: repair.message || initialProbe.detail, - }; - } - - const repairedProbe = deps.probe(sandboxName, { - attempts: INFERENCE_ROUTE_POST_REPAIR_PROBE_ATTEMPTS, - delayMs: INFERENCE_ROUTE_POST_REPAIR_PROBE_DELAY_MS, - }); - if (!quiet) { - if (repairedProbe.healthy) { - log(" inference.local route repaired."); - } else if (repairedProbe.broken) { - error(" Warning: inference.local is still unavailable after DNS proxy repair."); + if (!patch.ok && patch.reason) { + error(` Warning: OpenShell VM DNS monkeypatch did not apply: ${patch.reason}`); + } else if (patchedProbe?.broken) { + error( + " Warning: OpenShell VM DNS monkeypatch completed but inference.local is still unavailable.", + ); + } } } return { - healthy: repairedProbe.healthy, + healthy: false, repairAttempted: true, - detail: repairedProbe.detail, + detail: repairDetail, }; } @@ -566,13 +472,6 @@ function repairSandboxInferenceRouteIfNeeded( probe: (name, options) => probeSandboxInferenceRoute(name, agent, options), shouldApplyVmDnsMonkeypatch, applyVmDnsMonkeypatch: applyOpenShellVmDnsMonkeypatch, - reapplyVmInferenceRoute: (name, sandbox) => - reapplyVmInferenceRoute(name, sandbox, agent, gatewayName), - repairLegacyDnsProxy: (name, isQuiet) => - runSetupDnsProxy( - { gatewayName, sandboxName: name }, - { log: isQuiet ? () => undefined : console.log }, - ), assertRouteCompatible: (name, sandbox) => { if (sandbox) assertSandboxGatewayRouteCompatible(name, sandbox, gatewayName); }, @@ -622,7 +521,7 @@ function printUnrecoverableInferenceRoute( { repairAttempted = true }: { repairAttempted?: boolean } = {}, ): void { const reason = repairAttempted - ? `inference.local is still unavailable inside '${sandboxName}' after DNS and route repair.` + ? `inference.local is still unavailable inside '${sandboxName}' after route repair.` : `the authoritative inference.local probe inside '${sandboxName}' did not return a trusted result.`; const boundedDetail = sanitizeRouteValueForDisplay(redact(detail)) .replace(/\s+/g, " ") diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 5d179fb4bfa..4cec2374373 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -317,16 +317,6 @@ async function autoCreateSandboxFromSource( snapshotExit(1); } - // DNS proxy is only meaningful for the kubernetes driver (matches onboard.ts). - const dnsScript = path.join(ROOT, "scripts", "setup-dns-proxy.sh"); - const srcDriver = (srcEntry as { openshellDriver?: string | null }).openshellDriver; - if (srcDriver === "kubernetes" && fs.existsSync(dnsScript)) { - const srcGatewayName = resolveSandboxGatewayName( - srcEntry as { gatewayName?: string | null; gatewayPort?: number | null }, - ); - run(["bash", dnsScript, srcGatewayName, dstName], { ignoreError: true }); - } - // Register dst in the NemoClaw registry, cloning most fields from src. // Policies are cleared here — the caller replays them from the snapshot // manifest after the restore succeeds and writes them back into this entry. diff --git a/src/lib/domain/dns/setup-proxy.test.ts b/src/lib/domain/dns/setup-proxy.test.ts deleted file mode 100644 index 94575ebf817..00000000000 --- a/src/lib/domain/dns/setup-proxy.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - buildDnsProxyPython, - buildDnsReadyProbePython, - buildResolvConf, - isSafeDnsAddress, - parseVethGateway, - selectSandboxNamespace, - selectSandboxPod, -} from "./setup-proxy.js"; - -describe("DNS setup proxy domain helpers", () => { - it("selects a sandbox pod using fixed-string style matching", () => { - expect(selectSandboxPod("box[1]", "pod/alpha\npod/box[1]-abc\n")).toBe("box[1]-abc"); - expect(selectSandboxPod("missing", "pod/alpha\n")).toBeNull(); - }); - - it("falls back to the default veth gateway when discovery is empty", () => { - expect(parseVethGateway("10.200.0.1\n")).toBe("10.200.0.1"); - expect(parseVethGateway("\n")).toBe("10.200.0.1"); - }); - - it("selects the first sandbox namespace", () => { - expect(selectSandboxNamespace("other\nsandbox-ns\n")).toBe("sandbox-ns"); - expect(selectSandboxNamespace("other\n")).toBeNull(); - }); - - it("builds checked-in DNS proxy payloads and resolv.conf content", () => { - expect(buildDnsProxyPython()).toContain("sock.bind((BIND_IP, 53))"); - expect(buildDnsReadyProbePython("10.200.0.1")).toContain("10.200.0.1"); - expect(buildResolvConf("10.200.0.1")).toBe("nameserver 10.200.0.1\noptions ndots:5\n"); - }); - - it("rejects unsafe DNS address strings", () => { - expect(isSafeDnsAddress("10.43.0.10")).toBe(true); - expect(isSafeDnsAddress("bad;rm")).toBe(false); - }); -}); diff --git a/src/lib/domain/dns/setup-proxy.ts b/src/lib/domain/dns/setup-proxy.ts deleted file mode 100644 index 930c71f7ba5..00000000000 --- a/src/lib/domain/dns/setup-proxy.ts +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export const DEFAULT_DNS_UPSTREAM = "8.8.8.8"; -export const DEFAULT_VETH_GATEWAY = "10.200.0.1"; - -export function buildDnsProxyPython(): string { - return String.raw`import socket, threading, os, sys - -UPSTREAM = (sys.argv[1] if len(sys.argv) > 1 else '8.8.8.8', 53) -BIND_IP = sys.argv[2] if len(sys.argv) > 2 else '0.0.0.0' - -sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind((BIND_IP, 53)) - -with open('/tmp/dns-proxy.pid', 'w') as pf: - pf.write(str(os.getpid())) - -msg = 'dns-proxy: {}:53 -> {}:{} pid={}'.format(BIND_IP, UPSTREAM[0], UPSTREAM[1], os.getpid()) -print(msg, flush=True) -with open('/tmp/dns-proxy.log', 'w') as log: - log.write(msg + '\n') - -def forward(data, addr): - try: - f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - f.settimeout(5) - f.sendto(data, UPSTREAM) - r, _ = f.recvfrom(4096) - sock.sendto(r, addr) - f.close() - except Exception: - pass - -while True: - d, a = sock.recvfrom(4096) - threading.Thread(target=forward, args=(d, a), daemon=True).start() -`; -} - -export function buildDnsReadyProbePython(vethGateway: string): string { - return String.raw` -import socket, sys -s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -s.settimeout(1) -try: - s.sendto(b'\x00\x1e\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01', - (${JSON.stringify(vethGateway)}, 53)) - data, _ = s.recvfrom(4096) - sys.stdout.write('ok' if data else '') -except Exception: - pass -`; -} - -// Kubernetes pod names append generated suffixes to the requested sandbox name. -// Keep substring matching so names such as "box[1]" match "pod/box[1]-abc", -// then strip the leading "pod/" prefix before returning the pod name. -export function selectSandboxPod(sandboxName: string, podsOutput: string): string | null { - for (const line of podsOutput.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.includes(sandboxName)) continue; - return trimmed.replace(/^pod\//, ""); - } - return null; -} - -export function parseVethGateway(output: string): string { - const trimmed = output.trim(); - return trimmed || DEFAULT_VETH_GATEWAY; -} - -export function selectSandboxNamespace(output: string): string | null { - return ( - output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.includes("sandbox")) ?? null - ); -} - -export function buildResolvConf(vethGateway: string): string { - return `nameserver ${vethGateway}\noptions ndots:5\n`; -} - -export function isSafeDnsAddress(value: string): boolean { - return /^[a-zA-Z0-9.:_-]+$/.test(value); -} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1ab8adbe338..b1101883829 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -155,7 +155,7 @@ const os = require("os"); const path = require("path"); const pRetry = require("p-retry"); const runner: typeof import("./runner") = require("./runner"); -const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, runFile, validateName } = runner; +const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); const { applyExtraProviderReconciliation, @@ -2909,15 +2909,6 @@ async function createSandboxWithBaseImageResolution( ); restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization - // DNS proxy — run a forwarder in the sandbox pod so the isolated - // sandbox namespace can resolve hostnames (fixes #626). - if (sandboxRuntimeFields.openshellDriver === "kubernetes") { - console.log(" Setting up sandbox DNS proxy..."); - runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { - ignoreError: true, - }); - } - require("./onboard/vm-dns-monkeypatch").applyOnboardVmDnsMonkeypatch( sandboxName, sandboxRuntimeFields, diff --git a/src/lib/platform.ts b/src/lib/platform.ts index a8a5ddb51ed..cf34b0f80c5 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -88,7 +88,7 @@ function containerCanReachHostLoopback( function shouldPatchCoredns(runtime: ContainerRuntime, opts: WslDetectionOptions = {}): boolean { // CoreDNS patching is needed for Colima and Podman (both use custom network bridges). - // On WSL2, the host DNS is not routable from k3s pods — skip and let setup-dns-proxy.sh handle it. + // OpenShell owns sandbox DNS on WSL2; this host-side CoreDNS patch is not applicable there. if (isWsl(opts)) return false; return runtime === "colima" || runtime === "podman"; } diff --git a/test/dns-proxy.test.ts b/test/dns-proxy.test.ts index d992136cf03..15dfa43bdfa 100644 --- a/test/dns-proxy.test.ts +++ b/test/dns-proxy.test.ts @@ -1,111 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; -const SETUP_DNS_PROXY = path.join(import.meta.dirname, "..", "scripts", "setup-dns-proxy.sh"); -const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); const FIX_COREDNS = path.join(import.meta.dirname, "..", "scripts", "fix-coredns.sh"); -describe("setup-dns-proxy.sh", () => { - it("exists and is executable", () => { - const stat = fs.statSync(SETUP_DNS_PROXY); - expect(stat.isFile()).toBe(true); - expect(stat.mode & 0o100).toBeTruthy(); - }); - - it("sources runtime.sh successfully", () => { - const result = spawnSync("bash", ["-c", `source "${RUNTIME_SH}"; echo ok`], { - encoding: /** @type {const} */ "utf-8", - env: { ...process.env }, - }); - expect(result.status).toBe(0); - expect(result.stdout.trim()).toBe("ok"); - }); - - it("exits with usage when no sandbox name provided", () => { - const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw"], { - encoding: /** @type {const} */ "utf-8", - env: { ...process.env }, - }); - expect(result.status).not.toBe(0); - expect(result.stderr + result.stdout).toMatch(/Usage:/i); - }); - - it("configures DNS proxy through kubectl and verifies sandbox DNS end to end", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-proxy-")); - const fakeBin = path.join(tmp, "bin"); - const dockerLog = path.join(tmp, "docker.log"); - fs.mkdirSync(fakeBin); - fs.writeFileSync( - path.join(fakeBin, "docker"), - `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> ${JSON.stringify(dockerLog)} -if [ "\${1:-}" = "ps" ]; then - echo "openshell-cluster-nemoclaw" - exit 0 -fi -if [ "\${1:-}" != "exec" ]; then - exit 1 -fi -shift # cluster name -shift # kubectl -cmd="$*" -case "$cmd" in - *"get service kube-dns"*) echo "10.43.0.10"; exit 0 ;; - *"get endpoints kube-dns"*) echo "10.42.0.15"; exit 0 ;; - *"get pods -n openshell -o name"*) echo "pod/box[1]-abc"; exit 0 ;; - *"ip addr show"*) echo "10.200.0.1"; exit 0 ;; - *"cat /tmp/dns-proxy.pid"*) echo "12345"; exit 0 ;; - *"cat /tmp/dns-proxy.log"*) echo "dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345"; exit 0 ;; - *"python3 -c"*) echo "ok"; exit 0 ;; - *"ls /run/netns/"*) echo "sandbox-ns"; exit 0 ;; - *"test -x"*) [[ "$cmd" == *"/usr/sbin/iptables"* ]] && exit 0 || exit 1 ;; - *"cat /etc/resolv.conf"*) echo "nameserver 10.200.0.1"; exit 0 ;; - *"getent hosts github.com"*) echo "140.82.112.4 github.com"; exit 0 ;; -esac -exit 0 -`, - { mode: 0o755 }, - ); - - try { - const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw", "box[1]"], { - encoding: "utf-8", - env: { - ...process.env, - DOCKER_HOST: "unix:///tmp/fake-docker.sock", - PATH: `${fakeBin}:${process.env.PATH || ""}`, - }, - timeout: 15000, - }); - const output = `${result.stdout}${result.stderr}`; - expect(result.status).toBe(0); - expect(output).toContain("Setting up DNS proxy in pod 'box[1]-abc'"); - expect(output).toContain("DNS verification: 4 passed, 0 failed"); - - const calls = fs.readFileSync(dockerLog, "utf-8"); - expect(calls).toContain("get service kube-dns"); - expect(calls).not.toContain("get endpoints kube-dns"); - expect(calls).toContain("kube-system"); - expect(calls).toContain("nohup python3 -u /tmp/dns-proxy.py"); - expect(calls).toContain("10.43.0.10"); - expect(calls).toContain("10.200.0.1"); - expect(calls).toContain("/usr/sbin/iptables"); - expect(calls).toContain("--dport 53"); - expect(calls).toContain("cp /etc/resolv.conf /tmp/resolv.conf.orig"); - expect(calls).not.toContain("nsenter"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - describe("fix-coredns.sh", () => { it("exists and is executable", () => { const stat = fs.statSync(FIX_COREDNS); diff --git a/test/internal-cli.test.ts b/test/internal-cli.test.ts index 8027c5d8685..de7cb7c6455 100644 --- a/test/internal-cli.test.ts +++ b/test/internal-cli.test.ts @@ -18,18 +18,6 @@ describe("internal oclif namespace", () => { expect(result.stdout).toContain("nemoclaw internal dns fix-coredns [gateway-name]"); }); - it("exposes setup-proxy as an oclif-routed internal subcommand", () => { - const result = spawnSync(process.execPath, [CLI, "internal", "dns", "setup-proxy", "--help"], { - encoding: "utf-8", - }); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("Internal: configure sandbox DNS proxy"); - expect(result.stdout).toContain( - "nemoclaw internal dns setup-proxy ", - ); - }); - it("exposes uninstall plan commands through oclif routing", () => { const result = spawnSync( process.execPath, diff --git a/test/internal-commands-docs.test.ts b/test/internal-commands-docs.test.ts index d3512298f1b..a5ab04b0609 100644 --- a/test/internal-commands-docs.test.ts +++ b/test/internal-commands-docs.test.ts @@ -24,9 +24,8 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; - -import { getRegisteredOclifCommandsMetadata } from "../src/lib/cli/oclif-metadata"; import { renderAgentVariantPage } from "../scripts/sync-agent-variant-docs"; +import { getRegisteredOclifCommandsMetadata } from "../src/lib/cli/oclif-metadata"; const repoRoot = path.resolve(import.meta.dirname, ".."); @@ -86,7 +85,7 @@ describe("internal command documentation (#3782)", () => { it("registers the hidden internal command family", () => { // Guards against the manifest silently losing the family (which would make // the documentation assertions below vacuously pass). - expect(internalIds.length).toBeGreaterThanOrEqual(9); + expect(internalIds.length).toBeGreaterThanOrEqual(8); }); it.each(references)("documents every hidden internal command in $name", ({ text, binary }) => { diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts index d2984d6fd3d..823dfedcf14 100644 --- a/test/sandbox-connect-inference/helpers.ts +++ b/test/sandbox-connect-inference/helpers.ts @@ -378,50 +378,6 @@ if ( process.exit(0); } -if (cmd.includes("get service kube-dns")) { - process.stdout.write("10.43.0.10"); - process.exit(0); -} -if (cmd.includes("get endpoints kube-dns")) { - process.stdout.write("10.42.0.15"); - process.exit(0); -} -if (cmd.includes("get pods -n openshell -o name")) { - process.stdout.write("pod/${sandboxName}-abc\\n"); - process.exit(0); -} -if (cmd.includes("ip addr show")) { - process.stdout.write("10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.pid")) { - process.stdout.write("12345\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.log")) { - process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); - process.exit(0); -} -if (cmd.includes("python3 -c")) { - process.stdout.write("ok"); - process.exit(0); -} -if (cmd.includes("ls /run/netns/")) { - process.stdout.write("sandbox-ns\\n"); - process.exit(0); -} -if (cmd.includes("test -x")) { - process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); -} -if (cmd.includes("cat /etc/resolv.conf")) { - process.stdout.write("nameserver 10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("getent hosts github.com")) { - process.stdout.write("140.82.112.4 github.com\\n"); - process.exit(0); -} - process.exit(0); `, ); diff --git a/test/sandbox-connect-inference/route-swap-repair.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts index e38dc87eaea..212ffd16ac6 100644 --- a/test/sandbox-connect-inference/route-swap-repair.test.ts +++ b/test/sandbox-connect-inference/route-swap-repair.test.ts @@ -67,7 +67,6 @@ describe("sandbox connect inference route swap (#1248)", () => { 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', - 'BROKEN 503 {"error":"upstream unavailable"}', "OK 200", ], }, @@ -136,7 +135,6 @@ describe("sandbox connect inference route swap (#1248)", () => { 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', 'BROKEN 503 {"error":"upstream unavailable"}', - 'BROKEN 503 {"error":"upstream unavailable"}', "OK 200", ], writeOllamaProxyState: false, diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 741c7683df3..067afc205a5 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -1,15 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "child_process"; // Verify sandbox names stay validated and out of raw shell command strings. -import fs from "fs"; -import os from "os"; import path from "path"; import { describe, expect, it } from "vitest"; -import { writeOkOpenshell } from "./helpers/onboard-openshell-fixture"; - describe("sandboxName command hardening in onboard.js", () => { it("re-validates sandboxName at the createSandbox boundary", async () => { const onboardModule = await import("../src/lib/onboard.js"); @@ -28,125 +23,6 @@ describe("sandboxName command hardening in onboard.js", () => { ).rejects.toThrow(/Invalid sandbox name/); }); - it("runs setup-dns-proxy.sh through the argv helper instead of bash -c interpolation", () => { - const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dns-argv-")); - const fakeBin = path.join(tmpDir, "bin"); - const scriptPath = path.join(tmpDir, "create-sandbox-dns-argv.cjs"); - const sourceModule = (...segments: string[]) => - JSON.stringify(path.join(repoRoot, "src", "lib", ...segments)); - const onboardPath = sourceModule("onboard.ts"); - const runnerPath = sourceModule("runner.ts"); - const registryPath = sourceModule("state", "registry.ts"); - const preflightPath = sourceModule("onboard", "preflight.ts"); - const credentialsPath = sourceModule("credentials", "store.ts"); - const streamPath = sourceModule("sandbox", "create-stream.ts"); - - fs.mkdirSync(fakeBin, { recursive: true }); - writeOkOpenshell(fakeBin); - fs.writeFileSync( - scriptPath, - String.raw` -const runner = require(${runnerPath}); -const registry = require(${registryPath}); -const preflight = require(${preflightPath}); -const credentials = require(${credentialsPath}); -const sandboxCreateStream = require(${streamPath}); -for (const key of Object.keys(process.env)) { - if (/^(NEMOCLAW|OPENSHELL)_/.test(key) || key === "CHAT_UI_URL") { - delete process.env[key]; - } -} -process.env.NEMOCLAW_OPENSHELL_BIN = ${JSON.stringify(path.join(fakeBin, "openshell"))}; -const commands = []; -const asText = (command) => Array.isArray(command) ? command.join(" ") : String(command); -runner.run = (command, opts = {}) => { - commands.push({ type: "run", command: asText(command), env: opts.env || null }); - return { status: 0 }; -}; -runner.runFile = (file, args = [], opts = {}) => { - commands.push({ type: "runFile", file, args, command: asText([file, ...args]), env: opts.env || null }); - return { status: 0 }; -}; -runner.runCapture = (command) => { - const text = asText(command); - if (text.includes("sandbox get my-assistant")) return ""; - if (text.includes("sandbox list")) return "my-assistant Ready"; - if (text.includes("forward list")) return "my-assistant 127.0.0.1 18789 12345 running"; - if (text.includes("sandbox exec") && text.includes("http://localhost:") && text.includes("/health")) return "200"; - if (text === "uname -r") return "6.8.0"; - return ""; -}; -registry.getSandbox = () => null; -registry.getDisabledChannels = () => []; -registry.registerSandbox = () => true; -registry.removeSandbox = () => true; -registry.updateSandbox = () => true; -preflight.checkPortAvailable = async () => ({ ok: true }); -credentials.prompt = async () => ""; -sandboxCreateStream.streamSandboxCreate = async () => ({ - status: 0, - output: "Built image openshell/sandbox-from:123\nCreated sandbox: my-assistant", - sawProgress: true, -}); -const { createSandbox } = require(${onboardPath}); -(async () => { -try { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; - process.env.NEMOCLAW_NON_INTERACTIVE = "1"; - process.env.NEMOCLAW_HEALTH_POLL_COUNT = "1"; - Object.defineProperty(process, "platform", { value: "darwin" }); - Object.defineProperty(process, "arch", { value: "x64" }); - const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); - console.log(JSON.stringify({ sandboxName, commands })); -} catch (error) { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); -} -})(); -`, - ); - - try { - const result = spawnSync( - process.execPath, - [ - "--require", - path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), - scriptPath, - ], - { - cwd: repoRoot, - encoding: "utf-8", - env: { HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, - timeout: 30_000, - }, - ); - expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); - const payloadLine = result.stdout - .trim() - .split("\n") - .reverse() - .find((line) => line.startsWith("{") && line.endsWith("}")); - expect(payloadLine).toBeTruthy(); - const payload = JSON.parse(payloadLine!); - const dnsCommand = payload.commands.find( - (entry: { type: string; args: string[] }) => - entry.type === "runFile" && entry.args[0]?.endsWith("setup-dns-proxy.sh"), - ); - expect(dnsCommand).toBeTruthy(); - expect(dnsCommand.file).toBe("bash"); - expect(dnsCommand.args).toEqual([ - expect.stringMatching(/setup-dns-proxy\.sh$/), - "nemoclaw", - "my-assistant", - ]); - expect(dnsCommand.command).not.toContain("bash -c"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("builds openshell argv with an explicit openshellBinary override", async () => { const onboardModule = await import("../src/lib/onboard.js"); const onboard = onboardModule as unknown as { diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index 0b405f0453a..3c37c5be8d9 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -37,7 +37,6 @@ export type ConnectHarness = { resolveAgentConfigSpy: MockInstance; runAutoPairSpy: MockInstance; runOpenshellSpy: MockInstance; - runSetupDnsProxySpy: MockInstance; spawnSyncSpy: MockInstance; withGatewayRouteMutationLockSpy: MockInstance; writeSandboxConfigSpy: MockInstance; @@ -100,7 +99,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne const runtime = requireDist("../../src/lib/adapters/openshell/runtime.js"); const resolve = requireDist("../../src/lib/adapters/openshell/resolve.js"); const agentRuntime = requireDist("../../src/lib/agent/runtime.js"); - const dns = requireDist("../../src/lib/actions/dns/index.js"); const gatewayState = requireDist("../../src/lib/actions/sandbox/gateway-state.js"); const processRecovery = requireDist("../../src/lib/actions/sandbox/process-recovery.js"); const autoPairApproval = requireDist("../../src/lib/actions/sandbox/auto-pair-approval.js"); @@ -160,7 +158,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne (async (_gatewayName: string, operation: () => Promise | unknown) => await operation())) as never, ); - const runSetupDnsProxySpy = vi.spyOn(dns, "runSetupDnsProxy").mockReturnValue({ exitCode: 0 }); const applyVmDnsMonkeypatchSpy = vi .spyOn(vmDnsMonkeypatch, "applyOpenShellVmDnsMonkeypatch") .mockReturnValue({ attempted: true, changed: true, ok: true, status: "applied" }); @@ -262,7 +259,6 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne resolveAgentConfigSpy, runAutoPairSpy, runOpenshellSpy, - runSetupDnsProxySpy, spawnSyncSpy, withGatewayRouteMutationLockSpy, writeSandboxConfigSpy, From d7011154d86b46f8d9b61c69bd35c5b051a7f711 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 15 Jul 2026 01:20:49 -0700 Subject: [PATCH 5/5] test(openshell): cover Kubernetes snapshot restore Signed-off-by: Aaron Erickson --- .../snapshot-restore-clone-ports.test.ts | 67 +++++++++++++++++++ .../sandbox/snapshot-restore-test-fixture.ts | 3 +- src/lib/actions/sandbox/snapshot.ts | 2 +- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts index 77564061353..3a322cda56f 100644 --- a/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-clone-ports.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -26,6 +27,72 @@ vi.mock("../../onboard/dashboard-port", () => ({ beforeEach(f.resetSnapshotRestoreMocks); afterEach(f.cleanupSnapshotRestoreMocks); +describe("runSandboxSnapshot restore: new Kubernetes destination", () => { + it("restores a Kubernetes clone after Ready without invoking the retired DNS proxy", async () => { + let registeredClone: f.SandboxRecord | null = null; + f.registerSandboxMock.mockImplementation( + (entry) => (registeredClone = entry as f.SandboxRecord), + ); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "kubernetes", + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : registeredClone, + ); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const actualExistsSync = fs.existsSync; + vi.spyOn(fs, "existsSync").mockImplementation( + (candidate) => + String(candidate) === "/repo/scripts/setup-dns-proxy.sh" || actualExistsSync(candidate), + ); + f.streamSandboxCreateMock.mockImplementationOnce(async (_command, _args, _env, options) => { + expect(options?.readyCheck?.()).toBe(true); + return { status: 0, output: "", sawProgress: true, forcedReady: false }; + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + + expect(f.streamSandboxCreateMock).toHaveBeenCalledOnce(); + expect(f.streamSandboxCreateMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([ + "sandbox", + "create", + "--name", + "beta", + "--from", + "nemoclaw-alpha:live", + ]), + ); + expect( + f.captureOpenshellMock.mock.calls.filter(([args]) => args.join(" ") === "sandbox list"), + ).toHaveLength(3); + expect(f.registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "beta", + gatewayName: "nemoclaw", + openshellDriver: "kubernetes", + }), + ); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); + expect(f.runnerRunMock).not.toHaveBeenCalled(); + }); +}); + describe("runSandboxSnapshot restore: clone dashboard port identity", () => { it("allocates the auto-created clone its own dashboard port instead of inheriting the source's (#6746)", async () => { let registeredClone: f.SandboxRecord | null = null; diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts index f0c072618ae..502746a1154 100644 --- a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -141,6 +141,7 @@ export const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); export const registerSandboxMock = vi.fn(); export const updateSandboxMock = vi.fn(); export const restoreSandboxStateMock = vi.fn(); +export const runnerRunMock = vi.fn(() => ({ status: 0 })); export const runOpenshellMock = vi.fn((args: string[]) => { args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete"); return { status: 0, output: "" }; @@ -197,7 +198,7 @@ vi.mock("../../policy", () => ({ vi.mock("../../runner", () => ({ ROOT: "/repo", - run: vi.fn(() => ({ status: 0 })), + run: runnerRunMock, shellQuote: (value: string) => `'${value}'`, validateName: vi.fn((value: string) => value), })); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 4cec2374373..d34d82d5163 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -42,7 +42,7 @@ import { } from "../../onboard/observability-policy-presets"; import { normalizePolicyTierName } from "../../onboard/policy-tier-suppression"; import * as policies from "../../policy"; -import { ROOT, run, validateName } from "../../runner"; +import { ROOT, validateName } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import { streamSandboxCreate } from "../../sandbox/create-stream"; import * as shields from "../../shields";