diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index 151d0af76b0..888d6a4a063 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -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 ` | 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-` 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. diff --git a/src/lib/actions/uninstall/run-plan-homebrew-openshell.test.ts b/src/lib/actions/uninstall/run-plan-homebrew-openshell.test.ts new file mode 100644 index 00000000000..e2bf3d9ae54 --- /dev/null +++ b/src/lib/actions/uninstall/run-plan-homebrew-openshell.test.ts @@ -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([]); +}); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 71b5280d965..19d496fc0ee 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -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)); }), diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index be911347d5e..de580eb57cb 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -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, @@ -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);