Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/changelog/2026-08-18.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/inference/set-up-openai-compatible-endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepage

</AgentOnly>

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.

Expand Down
7 changes: 2 additions & 5 deletions src/lib/onboard/docker-driver-gateway-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard/docker-driver-gateway-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions src/lib/onboard/docker-driver-gateway-local-tls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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 {
Expand Down
79 changes: 67 additions & 12 deletions src/lib/onboard/experimental/portable-host-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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)", () => {
Expand All @@ -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();
});

Expand Down
10 changes: 9 additions & 1 deletion src/lib/onboard/experimental/portable-host-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ function validateOwnedConfigAuthority(input: {
if (socketPath) assertOwnedDescendants(runtimeDir, path.dirname(socketPath));
}

function ensureRegistryContainer(
function ensurePortableSandboxNetwork(
env: NodeJS.ProcessEnv,
docker: NonNullable<PortableHostPreparationDeps["docker"]>,
networkName: string,
Expand All @@ -367,7 +367,13 @@ function ensureRegistryContainer(
"Creating the portable sandbox network",
);
}
}

function ensureRegistryContainer(
env: NodeJS.ProcessEnv,
docker: NonNullable<PortableHostPreparationDeps["docker"]>,
networkName: string,
): void {
const inspection = docker(
[
"inspect",
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -659,6 +666,7 @@ export const portableHostPreparationInternals = {
REGISTRY_FRAGMENT,
PORTABLE_CONTAINERS_CONF,
ensurePortableHostGatewayAlias,
ensurePortableSandboxNetwork,
portableHostGatewayAliasState,
validateOwnedConfigAuthority,
resolvePodmanDockerHost,
Expand Down
30 changes: 30 additions & 0 deletions src/lib/onboard/experimental/portable-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion src/lib/onboard/experimental/portable-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
21 changes: 14 additions & 7 deletions src/lib/onboard/gateway-sandbox-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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()
Expand Down
42 changes: 21 additions & 21 deletions src/lib/onboard/host-service-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading