diff --git a/docs/changelog/2026-08-18.mdx b/docs/changelog/2026-08-18.mdx index 5163b448278..ec9ca945ce3 100644 --- a/docs/changelog/2026-08-18.mdx +++ b/docs/changelog/2026-08-18.mdx @@ -12,7 +12,7 @@ It also improves headless and local-inference onboarding, managed gateway recove - Experimental Portable Hermes onboarding now publishes `pending`, `configuring`, and `active` lifecycle receipts that bind the selected rootless Podman socket, exact container, startup command, policy, and registry identity. An active receipt supports `launch`, `connect`, `recover`, `start`, `stop`, `status`, and read-only `doctor` without falling back to Docker; incomplete receipts direct operators to resume onboarding, and unsupported commands fail before effects. Portable onboarding also keeps every agent off Docker-labeled recreation, requires the exact stopped container to reach its terminal `exited` state, accepts the managed Hermes startup command through the root helper, and settles receipt-owned Portable uninstall in one invocation. - The Portable network now configures `169.254.1.2/32` as the OpenShell host gateway and attaches the managed registry at the distinct `169.254.1.3` address. + The Portable network now configures `169.254.2.2/32` as the OpenShell host gateway and attaches the managed registry at the distinct `169.254.1.3` address. For more information, refer to the [NemoHermes CLI Commands Reference](/user-guide/hermes/reference/commands), [Set Up an OpenAI-Compatible Endpoint](/user-guide/hermes/inference/custom-endpoints/set-up-openai-compatible-endpoint), and [Uninstall NemoClaw](/user-guide/hermes/manage-sandboxes/operate-sandboxes/uninstall-nemoclaw). Related changes: [PR #9424](https://github.com/NVIDIA/NemoClaw/pull/9424), [PR #9447](https://github.com/NVIDIA/NemoClaw/pull/9447), [PR #9476](https://github.com/NVIDIA/NemoClaw/pull/9476), [PR #9509](https://github.com/NVIDIA/NemoClaw/pull/9509), [PR #9511](https://github.com/NVIDIA/NemoClaw/pull/9511), and [PR #9513](https://github.com/NVIDIA/NemoClaw/pull/9513). - OpenClaw `connect` now reports a Shields auto-relock that occurs during the active terminal session, keeps the session open, and prints the exact host command to lower Shields again. diff --git a/docs/inference/set-up-openai-compatible-endpoint.mdx b/docs/inference/set-up-openai-compatible-endpoint.mdx index eebbb43df9b..4c979d86d5e 100644 --- a/docs/inference/set-up-openai-compatible-endpoint.mdx +++ b/docs/inference/set-up-openai-compatible-endpoint.mdx @@ -132,8 +132,8 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepage -The portable profile creates the `openshell-docker` network on `169.254.1.0/24` and assigns `169.254.1.2/32` to the host loopback interface. -On that network, `169.254.1.2` is the host-gateway address for `host.openshell.internal` and carries authenticated mTLS plus sandbox-JWT callbacks to OpenShell on port `8080`. +The portable profile creates the `openshell-docker` network on `169.254.1.0/24` and assigns `169.254.2.2/32` to the host loopback interface. +On that route, `169.254.2.2` is the host-gateway address for `host.openshell.internal` and carries authenticated mTLS plus sandbox-JWT callbacks to OpenShell on port `8080`. The managed local registry uses the distinct address `169.254.1.3` on port `5000`. Keeping these addresses distinct prevents registry traffic from intercepting gateway callbacks. diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index 04a72c234c1..63da154fd2b 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -156,14 +156,14 @@ describe("buildDockerDriverGatewayEnv", () => { OPENSHELL_DRIVERS: "podman", CONTAINERS_CONF: "/tmp/nemoclaw-portable/containers.conf", OPENSHELL_BIND_ADDRESS: "0.0.0.0", - OPENSHELL_GRPC_ENDPOINT: "https://169.254.1.2:8080", + OPENSHELL_GRPC_ENDPOINT: "https://169.254.2.2:8080", NETAVARK_FW: "iptables", OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock", }); const toml = fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8"); expect(toml).toContain('compute_drivers = ["podman"]'); expect(toml).toContain("[openshell.drivers.podman]"); - expect(toml).toContain('host_gateway_ip = "169.254.1.2"'); + expect(toml).toContain('host_gateway_ip = "169.254.2.2"'); expect(toml).toContain('socket_path = "/run/user/1001/podman/podman.sock"'); expect(toml).not.toContain("supervisor_bin"); } finally { @@ -198,9 +198,7 @@ describe("buildDockerDriverGatewayEnv", () => { }); }); - describe("writeDockerGatewayDebEnvOverride", () => { - it("rejects an env file swapped to a symlink after opening without writing its target", () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-")); const envDir = path.join(tempHome, ".config", "openshell"); @@ -243,7 +241,6 @@ describe("writeDockerGatewayDebEnvOverride", () => { } }); - it("uses the provided HOME as the config root fallback when XDG_CONFIG_HOME is unset", () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-env-home-")); const envFile = path.join(tempHome, ".config", "openshell", "gateway.env"); diff --git a/src/lib/onboard/docker-driver-gateway-launch.test.ts b/src/lib/onboard/docker-driver-gateway-launch.test.ts index 615f3399f97..8dc274f416f 100644 --- a/src/lib/onboard/docker-driver-gateway-launch.test.ts +++ b/src/lib/onboard/docker-driver-gateway-launch.test.ts @@ -158,7 +158,7 @@ describe("docker-driver-gateway-launch", () => { it("writes the exact rootless socket only for the Podman driver", () => { const toml = buildDockerDriverGatewayConfigToml({ OPENSHELL_DRIVERS: "podman", - OPENSHELL_GRPC_ENDPOINT: "https://169.254.1.2:8080", + OPENSHELL_GRPC_ENDPOINT: "https://169.254.2.2:8080", OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker", OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test", OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock", diff --git a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts index bc98a1c7fea..08fbff2c6d2 100644 --- a/src/lib/onboard/docker-driver-gateway-local-tls.test.ts +++ b/src/lib/onboard/docker-driver-gateway-local-tls.test.ts @@ -239,7 +239,7 @@ describe("docker-driver-gateway-local-tls", () => { }) as never, }), ).toThrow("did not create a complete"); - expect(calls[0]).toEqual(expect.arrayContaining(["--server-san", "169.254.1.2"])); + expect(calls[0]).toEqual(expect.arrayContaining(["--server-san", "169.254.2.2"])); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } @@ -266,8 +266,11 @@ describe("docker-driver-gateway-local-tls", () => { expect(bundle.localTlsDir).toBe(path.join(stateDir, "tls")); expect(certgenCalls).toBe(0); - expect(Object.entries(contents).every(([filePath, content]) => - Object.is(fs.readFileSync(filePath, "utf-8"), content))).toBe(true); + expect( + Object.entries(contents).every(([filePath, content]) => + Object.is(fs.readFileSync(filePath, "utf-8"), content), + ), + ).toBe(true); expect(fs.statSync(paths.serverKeyPath).mode & 0o777).toBe(0o600); expect(fs.statSync(paths.clientKeyPath).mode & 0o777).toBe(0o600); } finally { diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index a629e8de9a5..35a2a11f740 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -423,21 +423,33 @@ describe("preparePortableExperimentalHost", () => { expect(sudo).not.toHaveBeenCalled(); }); - it("configures and verifies the portable gateway loopback alias before registry mutation (#9461)", () => { + it("validates the portable network before configuring the gateway alias (#9587)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-")); tempDirs.push(home); - const docker = vi - .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>() - .mockReturnValueOnce(result()) - .mockReturnValueOnce(result(0, JSON.stringify([{ Subnet: PORTABLE_DOCKER_NETWORK_SUBNET }]))) - .mockReturnValueOnce(result(0, `1 true ${PORTABLE_REGISTRY_IP}`)); + const events: string[] = []; + const dockerResults = [ + result(), + result(0, JSON.stringify([{ Subnet: PORTABLE_DOCKER_NETWORK_SUBNET }])), + result(0, `1 true ${PORTABLE_REGISTRY_IP}`), + ]; + const docker = vi.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>( + (args) => { + events.push(args[0] === "network" ? `docker network ${args[1]}` : `docker ${args[0]}`); + return dockerResults.shift() ?? result(); + }, + ); const ip = vi .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>() - .mockReturnValueOnce(result()) - .mockReturnValueOnce( - result(0, `1: lo inet ${PORTABLE_HOST_GATEWAY_IP}/32 scope global lo\n`), - ); - const sudo = vi.fn(() => result()); + .mockImplementation(() => { + events.push("ip inspect"); + return ip.mock.calls.length === 1 + ? result() + : result(0, `1: lo inet ${PORTABLE_HOST_GATEWAY_IP}/32 scope global lo\n`); + }); + const sudo = vi.fn(() => { + events.push("sudo alias"); + return result(); + }); preparePortableExperimentalHost( { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, @@ -455,6 +467,49 @@ describe("preparePortableExperimentalHost", () => { expect.any(Object), ); expect(docker).toHaveBeenCalledTimes(3); + expect(events).toEqual([ + "docker --version", + "docker network inspect", + "ip inspect", + "sudo alias", + "ip inspect", + "docker inspect", + ]); + }); + + it("does not configure the portable gateway alias when network creation fails (#9587)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-")); + tempDirs.push(home); + const docker = vi + .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>() + .mockReturnValueOnce(result()) + .mockReturnValueOnce(result(1)) + .mockReturnValueOnce(result(1, "create failed")); + const ip = vi.fn(() => result()); + const sudo = vi.fn(() => result()); + + expect(() => + preparePortableExperimentalHost( + { NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" }, + portablePreparationDeps(home, docker, { ip, sudo }), + undefined, + { simulateExistingPortableNetwork: false }, + ), + ).toThrow(/Creating the portable sandbox network failed: create failed/u); + + expect(docker.mock.calls.map(([args]) => args)).toEqual([ + ["--version"], + ["network", "inspect", "--format", "{{json .IPAM.Config}}", PORTABLE_DOCKER_NETWORK_NAME], + [ + "network", + "create", + "--subnet", + PORTABLE_DOCKER_NETWORK_SUBNET, + PORTABLE_DOCKER_NETWORK_NAME, + ], + ]); + expect(ip).not.toHaveBeenCalled(); + expect(sudo).not.toHaveBeenCalled(); }); it("refuses a conflicting portable gateway assignment before registry mutation (#9461)", () => { @@ -474,7 +529,7 @@ describe("preparePortableExperimentalHost", () => { }), ), ).toThrow(/address already has a conflicting host assignment/u); - expect(docker).toHaveBeenCalledTimes(1); + expect(docker.mock.calls.map(([args]) => args)).toEqual([["--version"]]); expect(sudo).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index 27d3517fe2a..bbafafdb497 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -340,7 +340,7 @@ function validateOwnedConfigAuthority(input: { if (socketPath) assertOwnedDescendants(runtimeDir, path.dirname(socketPath)); } -function ensureRegistryContainer( +function ensurePortableSandboxNetwork( env: NodeJS.ProcessEnv, docker: NonNullable, networkName: string, @@ -367,7 +367,13 @@ function ensureRegistryContainer( "Creating the portable sandbox network", ); } +} +function ensureRegistryContainer( + env: NodeJS.ProcessEnv, + docker: NonNullable, + networkName: string, +): void { const inspection = docker( [ "inspect", @@ -621,6 +627,7 @@ export function preparePortableExperimentalHost( timeout: REGISTRY_COMMAND_TIMEOUT_MS, })); requireDockerCompatibleCli(docker, podmanEnv); + ensurePortableSandboxNetwork(podmanEnv, docker, dockerNetworkName); const ip = deps.ip ?? ((args, childEnv) => @@ -659,6 +666,7 @@ export const portableHostPreparationInternals = { REGISTRY_FRAGMENT, PORTABLE_CONTAINERS_CONF, ensurePortableHostGatewayAlias, + ensurePortableSandboxNetwork, portableHostGatewayAliasState, validateOwnedConfigAuthority, resolvePodmanDockerHost, diff --git a/src/lib/onboard/experimental/portable-profile.test.ts b/src/lib/onboard/experimental/portable-profile.test.ts new file mode 100644 index 00000000000..39e9e1a136b --- /dev/null +++ b/src/lib/onboard/experimental/portable-profile.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { PORTABLE_DOCKER_NETWORK_SUBNET, PORTABLE_HOST_GATEWAY_IP } from "./portable-profile"; + +function ipv4ToNumber(address: string): number { + return address + .split(".") + .map((part) => Number(part)) + .reduce((value, part) => value * 256 + part, 0); +} + +function cidrRange(cidr: string): { first: number; last: number } { + const [baseAddress, prefixText] = cidr.split("/"); + const prefix = Number(prefixText); + const size = 2 ** (32 - prefix); + const first = Math.floor(ipv4ToNumber(baseAddress) / size) * size; + return { first, last: first + size - 1 }; +} + +describe("portable experimental profile network authority", () => { + it("keeps the host gateway outside the sandbox network subnet (#9587)", () => { + const gateway = ipv4ToNumber(PORTABLE_HOST_GATEWAY_IP); + const sandboxSubnet = cidrRange(PORTABLE_DOCKER_NETWORK_SUBNET); + + expect(gateway >= sandboxSubnet.first && gateway <= sandboxSubnet.last).toBe(false); + }); +}); diff --git a/src/lib/onboard/experimental/portable-profile.ts b/src/lib/onboard/experimental/portable-profile.ts index 6ff7ebd13de..58978d06b64 100644 --- a/src/lib/onboard/experimental/portable-profile.ts +++ b/src/lib/onboard/experimental/portable-profile.ts @@ -5,7 +5,7 @@ import { DEFAULT_DOCKER_DRIVER_NETWORK_NAME } from "./docker-network-authority"; export const EXPERIMENTAL_PROFILE_ENV = "NEMOCLAW_EXPERIMENTAL_PROFILE"; export const PORTABLE_EXPERIMENTAL_PROFILE = "portable"; -export const PORTABLE_HOST_GATEWAY_IP = "169.254.1.2"; +export const PORTABLE_HOST_GATEWAY_IP = "169.254.2.2"; export const PORTABLE_REGISTRY_IP = "169.254.1.3"; export const PORTABLE_DOCKER_NETWORK_NAME = DEFAULT_DOCKER_DRIVER_NETWORK_NAME; export const PORTABLE_DOCKER_NETWORK_SUBNET = "169.254.1.0/24"; diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index cfbd2b29dac..96647107cf7 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -10,6 +10,10 @@ import { tryAutoApplyUfwRule, verifySandboxBridgeGatewayReachableOrExit, } from "./gateway-sandbox-reachability"; +import { + PORTABLE_DOCKER_NETWORK_SUBNET, + PORTABLE_HOST_GATEWAY_IP, +} from "./experimental/portable-profile"; describe("gateway sandbox reachability route modeling", () => { it("parses Docker network IPAM config for subnet and gateway", () => { @@ -114,12 +118,15 @@ describe("isSandboxBridgeGatewayReachable", () => { expect(result.networkName).toBe("portable-custom"); }); - it("routes probes for the portable experimental profile through the OpenShell Podman host gateway", async () => { + it("routes portable probes to a host gateway outside the sandbox subnet (#9587)", async () => { vi.stubEnv("NEMOCLAW_EXPERIMENTAL_PROFILE", "portable"); const seen: { args: readonly string[] } = { args: [] }; const result = await isSandboxBridgeGatewayReachable({ - inspectNetworkImpl: () => ({ subnet: "10.89.0.0/24", gatewayIp: "10.89.0.1" }), + inspectNetworkImpl: () => ({ + subnet: PORTABLE_DOCKER_NETWORK_SUBNET, + gatewayIp: "169.254.1.1", + }), usesHostGatewayRouteImpl: () => false, runImpl: (args) => { seen.args = args; @@ -129,12 +136,12 @@ describe("isSandboxBridgeGatewayReachable", () => { expect(result).toMatchObject({ ok: true, - gatewayIp: "169.254.1.2", + gatewayIp: PORTABLE_HOST_GATEWAY_IP, routeKind: "portable_host_gateway", }); - expect(seen.args).toContain("host.openshell.internal:169.254.1.2"); - expect(seen.args).not.toContain("host.openshell.internal:10.89.0.1"); + expect(seen.args).toContain(`host.openshell.internal:${PORTABLE_HOST_GATEWAY_IP}`); + expect(seen.args).not.toContain("host.openshell.internal:169.254.1.1"); }); it("does not call a missing Docker network a firewall failure", async () => { @@ -613,7 +620,7 @@ describe("formatSandboxBridgeUnreachableMessage", () => { routeKind: "portable_host_gateway", networkName: "openshell-docker", subnet: "10.89.0.0/24", - gatewayIp: "169.254.1.2", + gatewayIp: "169.254.2.2", }); expect(msg).toContain("OpenShell Podman host gateway"); expect(msg).toContain("systemctl --user try-restart podman.service"); @@ -818,7 +825,7 @@ describe("verifySandboxBridgeGatewayReachableOrExit host-gateway retry", () => { const portableFailure = { ...hostGatewayTcpFailure, routeKind: "portable_host_gateway" as const, - gatewayIp: "169.254.1.2", + gatewayIp: "169.254.2.2", }; const reachabilityImpl = vi .fn() diff --git a/src/lib/onboard/host-service-reachability.test.ts b/src/lib/onboard/host-service-reachability.test.ts index 09cfd8e4fa5..b04bea4c176 100644 --- a/src/lib/onboard/host-service-reachability.test.ts +++ b/src/lib/onboard/host-service-reachability.test.ts @@ -124,7 +124,7 @@ describe("probeHostServiceSandboxReachability", () => { }); expect(result).toMatchObject({ ok: true, reason: "ok" }); - expect(capturedArgs).toContain("host.openshell.internal:169.254.1.2"); + expect(capturedArgs).toContain("host.openshell.internal:169.254.2.2"); expect(capturedArgs).not.toContain("host.openshell.internal:host-gateway"); expect(capturedArgs).not.toContain("host.openshell.internal:10.89.0.1"); }); @@ -174,26 +174,26 @@ describe("formatHostServiceUnreachableMessage", () => { expect(msg).toContain("nemoclaw onboard"); }); - it.each([ - "nemohermes", - "nemo-deepagents", - ])("uses the invoked %s CLI in the recovery command (#8712)", (invokedAs) => { - vi.stubEnv("NEMOCLAW_INVOKED_AS", invokedAs); - - const msg = formatHostServiceUnreachableMessage( - { - ok: false, - reason: "tcp_failed", - port: 8081, - networkName: "openshell-docker", - subnet: "172.18.0.0/16", - gatewayIp: "172.18.0.1", - }, - { serviceLabel: "managed llama.cpp server" }, - ); - - expect(msg).toContain(`Then rerun \`${invokedAs} onboard\`.`); - }); + it.each(["nemohermes", "nemo-deepagents"])( + "uses the invoked %s CLI in the recovery command (#8712)", + (invokedAs) => { + vi.stubEnv("NEMOCLAW_INVOKED_AS", invokedAs); + + const msg = formatHostServiceUnreachableMessage( + { + ok: false, + reason: "tcp_failed", + port: 8081, + networkName: "openshell-docker", + subnet: "172.18.0.0/16", + gatewayIp: "172.18.0.1", + }, + { serviceLabel: "managed llama.cpp server" }, + ); + + expect(msg).toContain(`Then rerun \`${invokedAs} onboard\`.`); + }, + ); it("falls back to result.port when no explicit port option is given", () => { const msg = formatHostServiceUnreachableMessage( diff --git a/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts b/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts index 8583e8ed4c8..fd35d60accc 100644 --- a/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts +++ b/test/e2e/support/openshell-gateway-auth-source-contract-helpers.test.ts @@ -102,7 +102,7 @@ describe("OpenShell gateway auth source contract helpers", () => { it("uses an explicit portable host gateway on the selected network", () => { const { args } = buildSandboxTokenContainerProbeInvocation({ dockerBin: "podman", - hostGatewayIp: "169.254.1.2", + hostGatewayIp: "169.254.2.2", networkName: "openshell-docker", payload: Buffer.from("sandbox request"), port: 8080, @@ -110,7 +110,7 @@ describe("OpenShell gateway auth source contract helpers", () => { }); expect(valuesAfterFlag(args, "--network")).toEqual(["openshell-docker"]); - expect(valuesAfterFlag(args, "--add-host")).toEqual(["host.openshell.internal:169.254.1.2"]); + expect(valuesAfterFlag(args, "--add-host")).toEqual(["host.openshell.internal:169.254.2.2"]); }); it("hard-fails unavailable Docker probe images on GitHub Actions", () => { diff --git a/test/openshell-0.0.101-migration-review.test.ts b/test/openshell-0.0.101-migration-review.test.ts index 8a4e64d807b..33a802e1d58 100644 --- a/test/openshell-0.0.101-migration-review.test.ts +++ b/test/openshell-0.0.101-migration-review.test.ts @@ -209,7 +209,7 @@ describe("OpenShell 0.0.101 migration review", () => { const podmanToml = buildDockerDriverGatewayConfigToml({ ...untrustedNewSurfaceInputs, OPENSHELL_DRIVERS: "podman", - OPENSHELL_GRPC_ENDPOINT: "https://169.254.1.2:8080", + OPENSHELL_GRPC_ENDPOINT: "https://169.254.2.2:8080", OPENSHELL_DOCKER_NETWORK_NAME: "openshell-podman", OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test", OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock",