Skip to content
Merged
12 changes: 12 additions & 0 deletions docs/manage-sandboxes/uninstall-nemoclaw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ It preserves resources from a separate OpenClaw installation while continuing to
| `--all-gateway-ports` | Uninstall every gateway port on the host instead of only the selected one. |
| `--gateway <name>` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT`. |

On macOS, Homebrew owns its OpenShell formula and executable links.
Full uninstall preserves OpenShell executable paths instead of assuming NemoClaw owns them.
When Homebrew confirms the `nvidia/openshell/openshell` formula, NemoClaw reports this separate removal command:

```bash
brew uninstall nvidia/openshell/openshell
```

Run this command only if you also want Homebrew to remove OpenShell.
When Homebrew is unavailable or does not confirm the formula, uninstall preserves the executable paths and reports why it could not confirm ownership.
Make `brew` available through `PATH` or inspect the formula before you remove OpenShell separately.

`NEMOCLAW_GATEWAY_PORT` selects the gateway instance to uninstall (`nemoclaw` for port `8080`, or `nemoclaw-<port>` for a non-default port).
For example, `NEMOCLAW_GATEWAY_PORT=9123 $$nemoclaw uninstall` selects `nemoclaw-9123` and its port-scoped state.
Do not use `--gateway` to select another instance; when supplied for compatibility, its value must match the derived name or uninstall stops before cleanup.
Expand Down
146 changes: 146 additions & 0 deletions src/lib/actions/uninstall/run-plan-homebrew-openshell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { expect, it, vi } from "vitest";

import {
type RunResult,
runUninstallPlan as runUninstallPlanBase,
type UninstallRunDeps,
} from "./run-plan";

const FORMULA = "nvidia/openshell/openshell";
const EXECUTABLE_NAMES = [
"openshell",
"openshell-driver-vm",
"openshell-gateway",
"openshell-sandbox",
] as const;

function ok(stdout = ""): RunResult {
return { status: 0, stdout, stderr: "" };
}

function runUninstallPlan(deps: UninstallRunDeps) {
return runUninstallPlanBase(
{ assumeYes: true, deleteModels: false, keepOpenShell: false },
{
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
gatewayName,
gatewayPort,
mode: "nemoclaw-managed",
source: "packaged-service",
endpoint: null,
stateDir: null,
supervisor: null,
requiredCapabilities: [],
}),
...deps,
},
);
}

function uninstallOpenShell(options: {
brewAvailable: boolean;
brewStatus: number | null;
platform?: NodeJS.Platform;
}) {
const home = "/tmp/nemoclaw-uninstall-test";
const executablePaths = EXECUTABLE_NAMES.map((name) => `${home}/.local/bin/${name}`);
const calls: string[][] = [];
const logs: string[] = [];
const removed: string[] = [];
const existing = new Set(executablePaths);
const remove = (target: string) => {
existing.delete(target);
removed.push(target);
return ok();
};
const result = runUninstallPlan({
commandExists: (command) =>
command === "openshell" || (command === "brew" && options.brewAvailable),
env: { HOME: home } as NodeJS.ProcessEnv,
existsSync: (target) => existing.has(String(target)),
hasPortableRuntimeCleanup: () => false,
isTty: true,
log: (line) => logs.push(line),
platform: options.platform ?? "darwin",
rmSync: vi.fn((target) => remove(String(target))),
run: vi.fn((command, args) => {
calls.push([command, ...args]);
return command === "sudo" && args[0] === "rm" && args[1] === "-f"
? remove(args[2])
: command === "openshell" && args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }]))
: command === "brew" && args[0] === "list"
? { status: options.brewStatus, stdout: "", stderr: "" }
: ok();
}),
runDocker: () => ok(),
});

return { calls, executablePaths, logs, remaining: [...existing], removed, result };
}

it("retains a Homebrew-managed OpenShell and reports its removal command (#8882)", () => {
const { calls, executablePaths, logs, remaining, removed, result } = uninstallOpenShell({
brewAvailable: true,
brewStatus: 0,
});

expect(result.exitCode).toBe(0);
expect(calls).toContainEqual(["brew", "list", "--formula", FORMULA]);
expect(calls.some((call) => call[0] === "brew" && call[1] === "uninstall")).toBe(false);
expect(removed).toEqual([]);
expect(remaining).toEqual(executablePaths);
expect(logs).toContain(
`Kept Homebrew-managed OpenShell. To remove it, run: brew uninstall ${FORMULA}`,
);
});

it.each([
{
label: "Homebrew is unavailable",
brewAvailable: false,
brewStatus: 0,
report: `Kept OpenShell executables because Homebrew is unavailable. If Homebrew manages OpenShell, make brew available through PATH, then run: brew uninstall ${FORMULA}`,
},
{
label: "the formula query fails",
brewAvailable: true,
brewStatus: 1,
report: `Kept OpenShell executables because Homebrew did not confirm ${FORMULA}. Check the formula before removing OpenShell.`,
},
{
label: "the formula query does not start",
brewAvailable: true,
brewStatus: null,
report: `Kept OpenShell executables because Homebrew did not confirm ${FORMULA}. Check the formula before removing OpenShell.`,
},
])("retains OpenShell when $label (#8882)", ({ brewAvailable, brewStatus, report }) => {
const { calls, executablePaths, logs, remaining, removed, result } = uninstallOpenShell({
brewAvailable,
brewStatus,
});

expect(result.exitCode).toBe(0);
expect(calls.filter((call) => call[0] === "brew")).toEqual(
brewAvailable ? [["brew", "list", "--formula", FORMULA]] : [],
);
expect(removed).toEqual([]);
expect(remaining).toEqual(executablePaths);
expect(logs).toContain(report);
});

it("removes managed OpenShell executables on Linux (#8882)", () => {
const { executablePaths, remaining, removed, result } = uninstallOpenShell({
brewAvailable: false,
brewStatus: 0,
platform: "linux",
});

expect(result.exitCode).toBe(0);
expect(new Set(removed)).toEqual(new Set(executablePaths));
expect(removed).toHaveLength(executablePaths.length);
expect(remaining).toEqual([]);
});
1 change: 1 addition & 0 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe("uninstall run plan", () => {
hasPortableRuntimeCleanup: () => false,
isTty: false,
log: (line) => logs.push(line),
platform: "linux",
rmSync: vi.fn((target: fs.PathLike) => {
removed.push(String(target));
}),
Expand Down
24 changes: 24 additions & 0 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,28 @@ function runOptional(
return false;
}

// Homebrew owns its formula and executable links. NemoClaw can report the
// removal command, but it must not infer that it installed the formula. (#8882)
const OPENSHELL_HOMEBREW_FORMULA = "nvidia/openshell/openshell";

function reportRetainedMacOsOpenShell(runtime: UninstallRuntime): void {
if (!runtime.commandExists("brew")) {
runtime.log(
`Kept OpenShell executables because Homebrew is unavailable. If Homebrew manages OpenShell, make brew available through PATH, then run: brew uninstall ${OPENSHELL_HOMEBREW_FORMULA}`,
);
return;
}
const installed = runtime.run("brew", ["list", "--formula", OPENSHELL_HOMEBREW_FORMULA], {
env: runtime.env,
stdio: "ignore",
});
runtime.log(
installed.status === 0
? `Kept Homebrew-managed OpenShell. To remove it, run: brew uninstall ${OPENSHELL_HOMEBREW_FORMULA}`
: `Kept OpenShell executables because Homebrew did not confirm ${OPENSHELL_HOMEBREW_FORMULA}. Check the formula before removing OpenShell.`,
);
}

function deleteSelectedGatewaySandbox(
runtime: UninstallRuntime,
gatewayName: string,
Expand Down Expand Up @@ -2830,6 +2852,8 @@ function executePlan(
runtime.log(binaryKeepMessage);
} else if (GATEWAY_PORT !== DEFAULT_GATEWAY_PORT) {
runtime.log("Keeping OpenShell binaries used by the default gateway service.");
} else if (runtime.platform === "darwin") {
reportRetainedMacOsOpenShell(runtime);
} else {
for (const target of paths.openshellInstallPaths)
removeFileWithOptionalSudo(target, runtime);
Expand Down
Loading