Skip to content

Commit 54c0804

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/n1x-express-install-8574
2 parents 19e1245 + 105c1df commit 54c0804

14 files changed

Lines changed: 661 additions & 17 deletions

docs/manage-sandboxes/uninstall-nemoclaw.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ Rerun `NEMOCLAW_GATEWAY_PORT=<port> $$nemoclaw uninstall` with the gateway port
115115
For an externally supervised authority, uninstall preserves the local gateway state used by the running process in both full and gateway-scoped cleanup.
116116
It also preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory.
117117
A custom-port uninstall does not stop or remove the default gateway service or its environment file.
118+
Uninstall does not stop an `openshell-gateway` process that another non-root user owns and that this installation did not record.
119+
It names the owner and process ID, leaves that process running, and continues with the remaining cleanup.
120+
If no other cleanup fails, uninstall exits with status `0` even though that process can keep its port in use.
121+
Uninstall still tries to stop a `root`-owned process and the gateway process that this installation recorded.
122+
If either of those stops fails, uninstall prints `sudo kill -9 <pid>` for the process.
123+
A gateway-scoped uninstall and every `--all-gateway-ports` pass exit nonzero after that failure.
124+
A single full uninstall reports the process and continues.
118125
Before scoped cleanup stops a Docker gateway process, including a managed default gateway service, NemoClaw requires two Docker namespace proofs.
119126
The selected Docker gateway configuration and any running gateway process must use the state-root-specific OpenShell sandbox namespace that NemoClaw generated.
120127
Because the supported OpenShell Podman schema does not expose `sandbox_namespace`, scoped Podman uninstall fails closed before signaling and preserves the gateway runtime evidence and local state.

docs/reference/commands.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4082,6 +4082,8 @@ For a managed dual-Station vLLM runtime, full uninstall revalidates the exact re
40824082
If that cleanup fails, uninstall exits nonzero, preserves its owner-only cleanup receipt, and tells you to resolve the reported peer error before retrying.
40834083
Pair cleanup can partially complete before an error; verify both Stations before the retry.
40844084
For an authenticated host-local vLLM runtime, full uninstall verifies the exact named container, NemoClaw ownership label, persisted API key, and authentication fingerprint before removing the container by its inspected ID.
4085+
When that ownership state is missing, full uninstall removes the reserved `nemoclaw-vllm` container only when Docker reports its NemoClaw managed label and a valid container ID.
4086+
An unlabeled container or malformed inspection remains in place and stops the remaining uninstall steps.
40854087
For managed llama.cpp, full uninstall verifies the exact named container and network ownership before removing both resources by their inspected IDs.
40864088
These host-local checks run before NemoClaw deletes their state.
40874089
If Docker is unavailable or a resource does not match its persisted ownership state, uninstall exits nonzero before the remaining uninstall steps and preserves that state for recovery.
@@ -4151,6 +4153,13 @@ Rerun `NEMOCLAW_GATEWAY_PORT=<port> $$nemoclaw uninstall` with the gateway port
41514153
For an externally supervised authority, uninstall preserves the selected local gateway state in both full and gateway-scoped cleanup.
41524154
It also preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory.
41534155
A custom-port uninstall does not stop or remove the default gateway service or its environment file.
4156+
Uninstall does not stop an `openshell-gateway` process that another non-root user owns and that this installation did not record.
4157+
It names the owner and process ID, leaves that process running, and continues with the remaining cleanup.
4158+
If no other cleanup fails, uninstall exits with status `0` even though that process can keep its port in use.
4159+
Uninstall still tries to stop a `root`-owned process and the gateway process that this installation recorded.
4160+
If either of those stops fails, uninstall prints `sudo kill -9 <pid>` for the process.
4161+
A gateway-scoped uninstall and every `--all-gateway-ports` pass exit nonzero after that failure.
4162+
A single full uninstall reports the process and continues.
41544163
Before scoped cleanup stops a Docker gateway process, including a managed default gateway service, NemoClaw requires two Docker namespace proofs.
41554164
The selected Docker gateway configuration and any running gateway process must use the state-root-specific OpenShell sandbox namespace that NemoClaw generated.
41564165
Because the supported OpenShell Podman schema does not expose `sandbox_namespace`, scoped Podman uninstall fails closed before signaling and preserves the gateway runtime evidence and local state.

docs/reference/troubleshooting.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,9 @@ openshell gateway list
432432

433433
If the gateway name and its port-scoped state remain, treat it as a second environment and select that port for cleanup.
434434
If the gateway is absent but the port still listens, cleanup did not stop the listener; follow the process or service remediation printed by uninstall before you retry.
435+
If uninstall reported that it kept an `openshell-gateway` process owned by another user running, that process still holds the port.
436+
This can happen after uninstall exits successfully because NemoClaw does not treat another user's process as a cleanup failure.
437+
Ask that user to stop the process, or onboard under a different `NEMOCLAW_GATEWAY_PORT`.
435438

436439
Remove one environment by selecting its port:
437440

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
8+
import { describe, expect, it, vi } from "vitest";
9+
10+
import { type RunResult, runUninstallPlan } from "./run-plan";
11+
12+
const HOST_GATEWAY_PID = 9999043;
13+
14+
function ok(stdout = ""): RunResult {
15+
return { status: 0, stdout, stderr: "" };
16+
}
17+
18+
function notFound(): RunResult {
19+
return { status: 1, stdout: "", stderr: "" };
20+
}
21+
22+
function uninstallWithHostGatewayOwnedBy(uid: number): {
23+
errors: string[];
24+
exitCode: number;
25+
} {
26+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-foreign-"));
27+
const errors: string[] = [];
28+
const psResults = new Map<string, RunResult>([
29+
["stat=", ok("S\n")],
30+
["args=", ok("/usr/local/bin/openshell-gateway\n")],
31+
["user=", ok("otheruser\n")],
32+
["uid=", ok(`${uid}\n`)],
33+
]);
34+
const run = (command: string, args: string[]): RunResult =>
35+
command === "pgrep"
36+
? args.some((arg) => arg.includes("openshell-gateway"))
37+
? ok(`${HOST_GATEWAY_PID}\n`)
38+
: notFound()
39+
: command === "ps"
40+
? (psResults.get(args.at(-1) ?? "") ?? notFound())
41+
: command === "openshell" && args.join(" ") === "gateway list -o json"
42+
? ok("[]")
43+
: ok();
44+
try {
45+
const result = runUninstallPlan(
46+
{ assumeYes: true, deleteModels: false, keepOpenShell: false },
47+
{
48+
commandExists: (command) => command === "pgrep" || command === "openshell",
49+
env: { HOME: tmpHome, NO_COLOR: "1" },
50+
error: (message) => errors.push(message),
51+
existsSync: () => false,
52+
isTty: false,
53+
kill: () => false,
54+
log: vi.fn(),
55+
requireCompleteGatewayProcessCleanup: true,
56+
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
57+
gatewayName,
58+
gatewayPort,
59+
mode: "nemoclaw-managed",
60+
source: "standalone",
61+
endpoint: null,
62+
stateDir: null,
63+
supervisor: null,
64+
requiredCapabilities: [],
65+
}),
66+
rmSync: vi.fn(),
67+
run,
68+
runDocker: () => ok(),
69+
},
70+
);
71+
return { errors, exitCode: result.exitCode };
72+
} finally {
73+
fs.rmSync(tmpHome, { force: true, recursive: true });
74+
}
75+
}
76+
77+
describe("uninstall with a host gateway owned by another user", () => {
78+
it("completes when the only unstoppable gateway process belongs to another user", () => {
79+
const { errors, exitCode } = uninstallWithHostGatewayOwnedBy((process.getuid?.() ?? 0) + 1);
80+
81+
expect(exitCode).toBe(0);
82+
expect(errors).toContainEqual(
83+
`Kept otheruser-owned host openshell-gateway process ${HOST_GATEWAY_PID} running. ` +
84+
"Cleanup does not stop a gateway process that another user owns.",
85+
);
86+
expect(errors).not.toContainEqual(
87+
"Cannot continue uninstall because host gateway process cleanup did not complete.",
88+
);
89+
});
90+
91+
it("still fails when the current user's own gateway process cannot be stopped", () => {
92+
const { errors, exitCode } = uninstallWithHostGatewayOwnedBy(process.getuid?.() ?? 0);
93+
94+
expect(exitCode).toBe(1);
95+
expect(errors).toContainEqual(
96+
"Cannot continue uninstall because host gateway process cleanup did not complete.",
97+
);
98+
});
99+
});

src/lib/actions/uninstall/run-plan-local-model-profile.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ function notFound(): RunResult {
2626
return { status: 1, stdout: "", stderr: "" };
2727
}
2828

29+
function dockerResults(results: ReadonlyMap<string, RunResult>) {
30+
return vi.fn((args: string[]) => results.get(JSON.stringify(args)) ?? ok());
31+
}
32+
33+
const ORPHANED_VLLM_INSPECT_ARGS = [
34+
"container",
35+
"inspect",
36+
"--format",
37+
'{{.Id}} {{index .Config.Labels "com.nvidia.nemoclaw.managed-vllm"}}',
38+
"nemoclaw-vllm",
39+
];
40+
41+
const RESERVED_INFERENCE_NAMES_ARGS = ["ps", "-a", "--format", "{{.Names}}"];
42+
2943
function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) {
3044
return runUninstallPlanBase(options, {
3145
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
@@ -79,9 +93,134 @@ function publishManagedLlamaOwner(
7993
}
8094

8195
describe("uninstall local model profile cleanup", () => {
96+
it("removes an orphaned host-local vLLM container only when its managed label is present (#8981)", () => {
97+
const containerId = "a".repeat(64);
98+
const runDocker = dockerResults(
99+
new Map([[JSON.stringify(ORPHANED_VLLM_INSPECT_ARGS), ok(`${containerId} true\n`)]]),
100+
);
101+
102+
const result = runUninstallPlan(
103+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
104+
{
105+
commandExists: (command) => command === "openshell" || command === "docker",
106+
env: { HOME: "/tmp/nemoclaw-uninstall-orphaned-vllm" } as NodeJS.ProcessEnv,
107+
existsSync: () => false,
108+
isTty: false,
109+
log: () => {},
110+
run: vi.fn(okWithKnownGatewayList),
111+
runDocker,
112+
},
113+
);
114+
115+
expect(result.exitCode).toBe(0);
116+
expect(runDocker).toHaveBeenCalledWith(
117+
["rm", "-f", containerId],
118+
expect.objectContaining({ timeout: 10_000 }),
119+
);
120+
});
121+
122+
it("preserves an orphaned host-local vLLM container without the managed label (#8981)", () => {
123+
const errors: string[] = [];
124+
const runDocker = dockerResults(
125+
new Map([
126+
[JSON.stringify(ORPHANED_VLLM_INSPECT_ARGS), ok(`${"b".repeat(64)} false\n`)],
127+
[JSON.stringify(RESERVED_INFERENCE_NAMES_ARGS), ok("nemoclaw-vllm\n")],
128+
]),
129+
);
130+
131+
const result = runUninstallPlan(
132+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
133+
{
134+
commandExists: (command) => command === "openshell" || command === "docker",
135+
env: { HOME: "/tmp/nemoclaw-uninstall-unlabeled-vllm" } as NodeJS.ProcessEnv,
136+
existsSync: () => false,
137+
error: (message) => errors.push(message),
138+
isTty: false,
139+
log: () => {},
140+
run: vi.fn(okWithKnownGatewayList),
141+
runDocker,
142+
},
143+
);
144+
145+
expect(result.exitCode).toBe(1);
146+
expect(runDocker.mock.calls.some(([args]) => args[0] === "rm")).toBe(false);
147+
expect(errors.join("\n")).toContain("remains after ownership-aware cleanup");
148+
});
149+
150+
it("preserves an orphaned host-local vLLM container after malformed inspection output (#8981)", () => {
151+
const errors: string[] = [];
152+
const runDocker = dockerResults(
153+
new Map([
154+
[JSON.stringify(ORPHANED_VLLM_INSPECT_ARGS), ok("not-a-container-id true\n")],
155+
[JSON.stringify(RESERVED_INFERENCE_NAMES_ARGS), ok("nemoclaw-vllm\n")],
156+
]),
157+
);
158+
159+
const result = runUninstallPlan(
160+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
161+
{
162+
commandExists: (command) => command === "openshell" || command === "docker",
163+
env: { HOME: "/tmp/nemoclaw-uninstall-malformed-vllm" } as NodeJS.ProcessEnv,
164+
existsSync: () => false,
165+
error: (message) => errors.push(message),
166+
isTty: false,
167+
log: () => {},
168+
run: vi.fn(okWithKnownGatewayList),
169+
runDocker,
170+
},
171+
);
172+
173+
expect(result.exitCode).toBe(1);
174+
expect(runDocker.mock.calls.some(([args]) => args[0] === "rm")).toBe(false);
175+
expect(errors.join("\n")).toContain("remains after ownership-aware cleanup");
176+
});
177+
178+
it("stops uninstall when orphaned host-local vLLM removal fails (#8981)", () => {
179+
const errors: string[] = [];
180+
const containerId = "c".repeat(64);
181+
const runDocker = dockerResults(
182+
new Map([
183+
[JSON.stringify(ORPHANED_VLLM_INSPECT_ARGS), ok(`${containerId} true\n`)],
184+
[JSON.stringify(["rm", "-f", containerId]), notFound()],
185+
]),
186+
);
187+
188+
const result = runUninstallPlan(
189+
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
190+
{
191+
commandExists: (command) => command === "openshell" || command === "docker",
192+
env: { HOME: "/tmp/nemoclaw-uninstall-vllm-removal-failure" } as NodeJS.ProcessEnv,
193+
existsSync: () => false,
194+
error: (message) => errors.push(message),
195+
isTty: false,
196+
log: () => {},
197+
run: vi.fn(okWithKnownGatewayList),
198+
runDocker,
199+
},
200+
);
201+
202+
expect(result.exitCode).toBe(1);
203+
expect(runDocker).toHaveBeenCalledWith(
204+
["rm", "-f", containerId],
205+
expect.objectContaining({ timeout: 10_000 }),
206+
);
207+
expect(errors.join("\n")).toContain("Could not remove orphaned managed inference container");
208+
expect(runDocker.mock.calls.some(([args]) => args[0] === "ps")).toBe(false);
209+
});
210+
82211
it("fails before generic Docker cleanup when a reserved inference name remains", () => {
83212
const errors: string[] = [];
84213
const psResults = new Map([
214+
[
215+
JSON.stringify([
216+
"container",
217+
"inspect",
218+
"--format",
219+
'{{.Id}} {{index .Config.Labels "com.nvidia.nemoclaw.managed-vllm"}}',
220+
"nemoclaw-vllm",
221+
]),
222+
notFound(),
223+
],
85224
[JSON.stringify(["ps", "-a", "--format", "{{.Names}}"]), ok("nemoclaw-llama-cpp\n")],
86225
[
87226
JSON.stringify(["ps", "-a", "--format", "{{.ID}} {{.Image}} {{.Names}}"]),

src/lib/actions/uninstall/run-plan.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan";
3131
import {
3232
cleanupManagedLlamaCppRuntimeForSandbox,
33+
HOST_LOCAL_VLLM_CONTAINER_NAME,
34+
HOST_LOCAL_VLLM_MANAGED_LABEL,
3335
type ManagedLlamaCppCleanupTarget,
3436
resolveManagedLlamaCppCleanupTarget,
3537
} from "../../inference/local-model-profile/cleanup";
@@ -1595,6 +1597,9 @@ function removeHostLocalModelRuntimes(paths: UninstallPaths, runtime: UninstallR
15951597
DUAL_STATION_VLLM_RUNTIME_RECEIPT_FILE,
15961598
].some((name) => runtime.existsSync(path.join(sharedRoot, name)));
15971599
if (!hasLlamaState && (!hasManagedKey || hasDistributedReceipt)) {
1600+
if (!hasManagedKey && !hasDistributedReceipt && !removeOrphanedManagedHostLocalVllm(runtime)) {
1601+
return false;
1602+
}
15981603
return true;
15991604
}
16001605
const result = runtime.runLocalModelRuntimeCleanup({
@@ -1608,6 +1613,34 @@ function removeHostLocalModelRuntimes(paths: UninstallPaths, runtime: UninstallR
16081613
return false;
16091614
}
16101615

1616+
function removeOrphanedManagedHostLocalVllm(runtime: UninstallRuntime): boolean {
1617+
if (!runtime.commandExists("docker")) return true;
1618+
const inspection = runtime.runDocker(
1619+
[
1620+
"container",
1621+
"inspect",
1622+
"--format",
1623+
`{{.Id}} {{index .Config.Labels ${JSON.stringify(HOST_LOCAL_VLLM_MANAGED_LABEL)}}}`,
1624+
HOST_LOCAL_VLLM_CONTAINER_NAME,
1625+
],
1626+
{ env: runtime.env, timeout: 10_000 },
1627+
);
1628+
if (inspection.status !== 0 || !inspection.stdout.trim()) return true;
1629+
const [containerId, managedLabel, ...extra] = inspection.stdout.trim().split(/\s+/);
1630+
if (!/^[0-9a-f]{64}$/u.test(containerId ?? "") || managedLabel !== "true" || extra.length > 0) {
1631+
return true;
1632+
}
1633+
const removal = runtime.runDocker(["rm", "-f", containerId], {
1634+
env: runtime.env,
1635+
timeout: 10_000,
1636+
});
1637+
if (removal.status === 0) return true;
1638+
runtime.error(
1639+
`Could not remove orphaned managed inference container '${HOST_LOCAL_VLLM_CONTAINER_NAME}'. NemoClaw did not start the remaining uninstall steps.`,
1640+
);
1641+
return false;
1642+
}
1643+
16111644
function managedLlamaCppCleanupPorts(homeDir: string, scopedToSelectedGateway: boolean): number[] {
16121645
const ports = new Set<number>([GATEWAY_PORT]);
16131646
if (scopedToSelectedGateway) return [...ports];

src/lib/inference/local-model-profile/cleanup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import {
4646
} from "../serving/vllm-host-local-lifecycle";
4747
import { loadManagedVllmApiKey, managedVllmStateDir } from "../vllm-api-key";
4848

49+
export { HOST_LOCAL_VLLM_CONTAINER_NAME, HOST_LOCAL_VLLM_MANAGED_LABEL };
50+
4951
const LLAMA_MANAGED_LABEL = "io.nvidia.nemoclaw.host-local-inference.managed";
5052
const LLAMA_PROVIDER_LABEL = "io.nvidia.nemoclaw.host-local-inference.provider";
5153
const LLAMA_SERVICE_LABEL = "io.nvidia.nemoclaw.host-local-inference.service";

src/lib/onboard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3502,6 +3502,7 @@ const {
35023502
buildOrphanedSandboxRollbackMessage,
35033503
ensureDashboardForward,
35043504
ensureAgentDashboardForward,
3505+
ensureFinalizationDashboardForward,
35053506
ensureAgentFixedForward,
35063507
fetchGatewayAuthTokenFromSandbox,
35073508
getDashboardForwardPort,
@@ -3522,7 +3523,6 @@ const {
35223523
sleep: sleepSeconds,
35233524
printAgentDashboardUi: agentOnboard.printDashboardUi,
35243525
});
3525-
35263526
const onboardRuntimeBoundary = new OnboardRuntimeBoundary({
35273527
toSessionUpdates: (updates: Record<string, unknown>) =>
35283528
toSessionUpdates(updates as Parameters<typeof toSessionUpdates>[0]),
@@ -4240,7 +4240,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
42404240
webSearchProvider: (config) => webSearchProviderForConfig(config),
42414241
},
42424242
finalizationDeps: {
4243-
ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : ensureDashboardForward(name, process.env.CHAT_UI_URL),
4243+
ensureAgentDashboardForward: (name, selectedAgent) => selectedAgent ? ensureAgentDashboardForward(name, selectedAgent) : ensureFinalizationDashboardForward(name),
42444244
setDefaultSandbox: registry.setDefault,
42454245
verifyWebSearchInsideSandbox,
42464246
toSessionUpdates: (updates) =>

src/lib/onboard/agent-dashboard-forward.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export async function ensureAgentDashboardForward(options: {
117117
return actualAgentDashboardPort;
118118
}
119119

120-
function replaceUrlPort(value: string, port: number): string {
120+
export function replaceUrlPort(value: string, port: number): string {
121121
try {
122122
const parsed = new URL(value.includes("://") ? value : `http://${value}`);
123123
parsed.port = String(port);

0 commit comments

Comments
 (0)