Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/manage-sandboxes/uninstall-nemoclaw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ It preserves resources from a separate OpenClaw installation while continuing to
| `--yes` | Skip the confirmation prompt. |
| `--keep-openshell` | Leave OpenShell binaries, NemoClaw-managed gateway service files, and local gateway state in place, and do not stop the host gateway process. |
| `--delete-models` | Delete every model reported by the host's local Ollama inventory and all non-credential data in the current user's shared `~/.cache/huggingface/` cache after managed model runtimes stop. Hugging Face authentication files remain. |
| `--destroy-user-data` | Also remove preserved user data in `rebuild-backups/`, `backups/`, and `sandboxes.json`. |
| `--destroy-user-data` | Also remove preserved user data in `rebuild-backups/`, `backups/`, and `sandboxes.json`. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. |
| `--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`. |

Expand Down Expand Up @@ -230,6 +230,7 @@ If validation, runtime cleanup, or temporary-claim retirement fails, uninstall e
Resolve the reported SSH, Docker, peer-host, or claim error before you retry.
Runtime cleanup can partially complete before an error, so inspect every cluster node before retrying.
When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state and preserves those shared host resources.
With `--destroy-user-data`, that scoped path removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified (for example odd `~/.nemoclaw/gateways/` entries or an unreadable gateway list). When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package and the other shared host resources.
A recorded sandbox that OpenShell reports as already removed leaves nothing to delete, so the scoped path reports it, finishes the remaining gateway cleanup, and exits `0`.
A sandbox that OpenShell cannot reach or refuses to delete still leaves state behind.
Uninstall exits nonzero and preserves the selected gateway registration, port-scoped state, and selected sandbox registry entry for a retry.
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4346,7 +4346,7 @@ Uninstall does not otherwise target the declared external directory.
| `--yes` | Skip the confirmation prompt |
| `--keep-openshell` | Leave OpenShell binaries, NemoClaw-managed gateway service files, and local gateway state in place, and do not stop the host gateway process |
| `--delete-models` | Outside portable cleanup, delete every model reported by the host's local Ollama inventory and all non-credential data in the current user's shared `~/.cache/huggingface/` cache after managed model runtimes stop. Hugging Face authentication files remain. Portable cleanup preserves both model stores and every Podman image. |
| `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`). Portable cleanup still retains its recovery record until later completed onboarding durably supersedes it. |
| `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`). Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. Portable cleanup still retains its recovery record until later completed onboarding durably supersedes it. |
| `--all-gateway-ports` | Uninstall every gateway port on the host, not only the port `NEMOCLAW_GATEWAY_PORT` selects |
| `--gateway <name>` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT` |

Expand Down Expand Up @@ -4452,6 +4452,7 @@ The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw

Outside portable cleanup, when uninstall confirms that no sibling gateways remain, it also removes shared host resources such as the gateway source clone, runtime state, and the Ollama auth proxy PID file.
When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving those shared host resources.
With `--destroy-user-data`, that scoped path removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified (for example odd `~/.nemoclaw/gateways/` entries or an unreadable gateway list). When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package and the other shared host resources.
If the OpenShell command is unavailable or its gateway list cannot be read, uninstall cannot confirm that the selected gateway is the last one, so it uses the same scoped path and preserves the shared resources.
When the command itself is unavailable, uninstall exits nonzero before OpenShell cleanup so you can restore the command and retry.

Expand All @@ -4467,8 +4468,8 @@ Decision matrix:
| Interactive TTY, preserved entries present, no env override | Prompts `Also remove them? [y/N]`. Default `N` keeps the entries. |
| Interactive TTY, user answers `y` | Removes the preserved entries in the selected gateway's state root; outside portable cleanup, a single-gateway uninstall also removes the remaining shared state. Portable cleanup preserves shared OpenShell resources and its retirement record. |
| Non-interactive (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or non-TTY shell) | Preserves the entries and prints a one-line notice. |
| `--destroy-user-data` | Skips the secondary user-data prompt and removes the preserved entries in the selected gateway's state root. The global `Proceed?` confirmation still applies unless `--yes` is also passed. |
| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the secondary user-data prompt and removes the preserved entries. The global `Proceed?` confirmation still applies unless `--yes` is also passed. |
| `--destroy-user-data` | Skips the secondary user-data prompt and removes the preserved entries in the selected gateway's state root. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. The global `Proceed?` confirmation still applies unless `--yes` is also passed. |
| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` | Skips the secondary user-data prompt and removes the preserved entries. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. The global `Proceed?` confirmation still applies unless `--yes` is also passed. |

The preserved entries survive uninstall as inert files on disk.
Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw <name> snapshot restore` can use them.
Expand Down Expand Up @@ -5144,7 +5145,7 @@ The following flags change defaults for commands that manage existing sandboxes.
| `NEMOCLAW_SHIELDS_SETTLE_MS` | milliseconds (default `750`, clamped to `0` to `10000`) | Settle window NemoClaw waits after re-applying a config lockdown (during shields auto-restore and `$$nemoclaw <name> shields up` drift remediation) before re-confirming the lock still holds. Detects when an in-sandbox reconciler changes config file permissions after lockdown and re-applies the lock; if NemoClaw cannot re-confirm the lock within the retry budget, shields stay down. This narrows the window in which a reconciler can revert permissions rather than eliminating it. The best-effort `chattr +i` immutable bit remains the only fully durable lock. Raise it on hosts where the gateway settles slowly. |
| `NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP` | Exactly `1` to opt in (`true`, `yes`, `0` are not accepted) | Applies to standalone `$$nemoclaw backup-all` runs. Skips running sandboxes whose in-sandbox SSH endpoint does not answer. It does not relax the installer's strict pre-upgrade backup, which still aborts if any registered sandbox is skipped or fails. Any uncommitted state since the last successful backup is not included in the skipped backup. |
| `NEMOCLAW_UNINSTALL_ALL_GATEWAY_PORTS` | `1` to opt in | Makes `$$nemoclaw uninstall` remove every gateway port on the host instead of only the port `NEMOCLAW_GATEWAY_PORT` selects. Equivalent to passing the `--all-gateway-ports` flag; the whole-host `Proceed?` confirmation still applies unless `--yes` is also passed. Each port runs as its own uninstall, and the variable is dropped from those runs so the sweep cannot re-enter itself. |
| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA` | `1` to opt in | Acknowledges data loss during `$$nemoclaw uninstall` and removes the otherwise-preserved entries (`rebuild-backups/`, `backups/`, `sandboxes.json`) under `~/.nemoclaw/`. Equivalent to passing the `--destroy-user-data` flag; the global `Proceed?` confirmation still applies unless `--yes` is also passed. |
| `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA` | `1` to opt in | Acknowledges data loss during `$$nemoclaw uninstall` and removes the otherwise-preserved entries (`rebuild-backups/`, `backups/`, `sandboxes.json`) under `~/.nemoclaw/`. Removes installer-managed user-local CLI shims under `~/.local/bin/` only when sibling evidence is unidentified. When a confirmed sibling gateway port remains, those shared shims stay with the shared npm CLI package. Equivalent to passing the `--destroy-user-data` flag; the global `Proceed?` confirmation still applies unless `--yes` is also passed. |

<AgentOnly variant="openclaw">
### Deprecated Brev Deployment
Expand Down
2 changes: 1 addition & 1 deletion src/lib/actions/root-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function help(): void {
` --delete-models${" ".repeat(19)}Remove all Ollama models and non-credential Hugging Face cache data (authentication files remain)`,
);
lines.push(
` --destroy-user-data${" ".repeat(15)}Remove preserved ~/.nemoclaw/ user data (rebuild-backups/, backups/, sandboxes.json)`,
` --destroy-user-data${" ".repeat(15)}Remove preserved ~/.nemoclaw/ user data; managed CLI shims unless a confirmed sibling remains`,
);
lines.push(
` --all-gateway-ports${" ".repeat(15)}Uninstall every gateway port on this host, not only NEMOCLAW_GATEWAY_PORT`,
Expand Down
151 changes: 130 additions & 21 deletions src/lib/actions/uninstall/run-plan-gateway-scan-entries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import os from "node:os";
import path from "node:path";

import { describe, expect, it, vi } from "vitest";
import {
withProvenManagedGatewayProcess,
writeManagedGatewayRuntimeProof,
} from "../../../../test/support/uninstall-managed-gateway-test-support";

import {
buildDockerDriverGatewayConfigToml,
ensureDockerDriverGatewayJwtBundle,
gatewayIdForStateDir,
} from "../../onboard/docker-driver-gateway-config";
import { resolveGatewayStateDirName } from "../../onboard/gateway-binding";
import {
type RunResult,
runUninstallPlan as runUninstallPlanBase,
Expand All @@ -23,8 +28,9 @@ function ok(stdout = ""): RunResult {
return { status: 0, stdout, stderr: "" };
}

function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) {
return runUninstallPlanBase(options, {
function withManagedGatewayAuthority(deps: UninstallRunDeps): UninstallRunDeps {
return withProvenManagedGatewayProcess({
isPortFree: () => true,
resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({
gatewayName,
gatewayPort,
Expand All @@ -39,14 +45,28 @@ function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps)
});
}

function runUninstallPlan(options: UninstallRunOptions, deps: UninstallRunDeps) {
return runUninstallPlanBase(options, withManagedGatewayAuthority(deps));
}

function okWithKnownGatewayList(command: string, args: readonly string[]): RunResult {
return command === "openshell" && args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }]))
: ok();
}

function okWithSiblingGatewayList(command: string, args: readonly string[]): RunResult {
return command === "openshell" && args[0] === "gateway" && args[1] === "list"
? ok(JSON.stringify([{ name: "nemoclaw" }, { name: "nemoclaw-9123" }]))
: ok();
}

const SCOPED_RETENTION_LOG =
"Sibling gateways remain; kept the shared NemoClaw CLI and shell shims.";
const SCOPED_PACKAGE_RETENTION_LOG =
"Sibling gateways remain; kept the shared NemoClaw CLI package.";
const DESTROY_SHIM_CLEANUP_LOG =
"Removed managed user-local CLI shims because --destroy-user-data was set.";

function managedWrapper(binName: string): string {
return [
Expand Down Expand Up @@ -80,14 +100,15 @@ function makeHome(prefix: string, entries: readonly string[]): { home: string; s
return { home, shims };
}

function writeScopedGatewayState(home: string): void {
const stateDir = path.join(home, ".local", "state", "nemoclaw", "openshell-docker-gateway");
function writeScopedGatewayState(home: string, port = 8080): void {
const stateDir = path.join(home, ".local", "state", "nemoclaw", resolveGatewayStateDirName(port));
const configPath = path.join(stateDir, "openshell-gateway.toml");
const jwtBundle = ensureDockerDriverGatewayJwtBundle(stateDir);
fs.writeFileSync(
path.join(stateDir, "openshell-gateway.toml"),
configPath,
buildDockerDriverGatewayConfigToml(
{
OPENSHELL_GRPC_ENDPOINT: "https://127.0.0.1:8080",
OPENSHELL_GRPC_ENDPOINT: `https://127.0.0.1:${String(port)}`,
OPENSHELL_LOCAL_TLS_DIR: path.join(stateDir, "tls"),
OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker",
OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test",
Expand All @@ -98,33 +119,55 @@ function writeScopedGatewayState(home: string): void {
),
{ mode: 0o600 },
);
fs.chmodSync(configPath, 0o600);
writeManagedGatewayRuntimeProof(stateDir, port);
}

/**
* Runs the plan and reports which shims are still on disk afterwards. Removal
* goes through the real filesystem, restricted to the temporary home, so the
* assertions read the outcome rather than the calls a test double recorded.
* Captures both `log` and `error` (runtime warnings) for assertion.
*/
function uninstall(home: string, shims: readonly string[]) {
function uninstall(
home: string,
shims: readonly string[],
options: Pick<UninstallRunOptions, "destroyUserData"> & {
run?: (command: string, args: readonly string[]) => RunResult;
} = {},
) {
const logs: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: (command) => command !== "docker" && command !== "lsof" && command !== "pgrep",
env: { HOME: home } as NodeJS.ProcessEnv,
existsSync: (target) => shims.includes(target) && fs.existsSync(target),
assumeYes: true,
deleteModels: false,
destroyUserData: options.destroyUserData ?? false,
keepOpenShell: true,
},
{
commandExists: (command) => command === "openshell",
env: { HOME: home, NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv,
existsSync: (target) => String(target).startsWith(home) && fs.existsSync(target),
hasPortableRuntimeCleanup: () => false,
isTty: false,
log: (line) => logs.push(line),
rmSync: vi.fn((target: fs.PathLike, options?: fs.RmOptions) => {
String(target).startsWith(home) ? fs.rmSync(target, options) : undefined;
error: (line) => logs.push(line),
rmSync: vi.fn((target: fs.PathLike, rmOptions?: fs.RmOptions) => {
String(target).startsWith(home) ? fs.rmSync(target, rmOptions) : undefined;
}),
run: vi.fn(okWithKnownGatewayList),
run: vi.fn(options.run ?? okWithKnownGatewayList),
runDocker: () => ok(""),
},
);
return { result, logs, survivors: shims.filter((shim) => fs.existsSync(shim)) };
}

function writeForeignCliShims(shims: readonly string[]): void {
for (const shim of shims) {
fs.writeFileSync(shim, "#!/usr/bin/env node\nconsole.log('foreign')\n", { mode: 0o755 });
}
}

describe("uninstall gateway-directory scan", () => {
it.each([
[".DS_Store"],
Expand Down Expand Up @@ -165,19 +208,85 @@ describe("uninstall gateway-directory scan", () => {
}
});

it("keeps the CLI shims for a desktop-metadata symlink (#7905)", () => {
const { home, shims } = makeHome("nemoclaw-uninstall-conservative-", []);
it.skipIf(process.platform === "win32")(
"keeps the CLI shims for a desktop-metadata symlink (#7905)",
() => {
const { home, shims } = makeHome("nemoclaw-uninstall-conservative-", []);
writeScopedGatewayState(home);
fs.symlinkSync(
"concealed-gateway-state",
path.join(home, ".nemoclaw", "gateways", ".DS_Store"),
);

try {
const { result, logs, survivors } = uninstall(home, shims);

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_RETENTION_LOG);
expect(survivors).toEqual(shims);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
},
);

it.each([
["not-a-port"],
["._"],
[".DS_Store/"],
])(
"removes managed CLI shims with --destroy-user-data when the gateways directory holds %s (#9277)",
(entry) => {
const { home, shims } = makeHome("nemoclaw-uninstall-destroy-shim-", [entry]);
writeScopedGatewayState(home);

try {
const { result, logs, survivors } = uninstall(home, shims, { destroyUserData: true });

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_PACKAGE_RETENTION_LOG);
expect(logs).toContain(DESTROY_SHIM_CLEANUP_LOG);
expect(logs).not.toContain(SCOPED_RETENTION_LOG);
expect(survivors).toEqual([]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
},
);

it("preserves foreign CLI files under --destroy-user-data without claiming removal (#9277)", () => {
const { home, shims } = makeHome("nemoclaw-uninstall-destroy-foreign-", ["not-a-port"]);
writeScopedGatewayState(home);
fs.symlinkSync(
"concealed-gateway-state",
path.join(home, ".nemoclaw", "gateways", ".DS_Store"),
);
writeForeignCliShims(shims);

try {
const { result, logs, survivors } = uninstall(home, shims);
const { result, logs, survivors } = uninstall(home, shims, { destroyUserData: true });

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_PACKAGE_RETENTION_LOG);
expect(logs).not.toContain(DESTROY_SHIM_CLEANUP_LOG);
expect(logs).not.toContain(SCOPED_RETENTION_LOG);
expect(logs.some((line) => line.includes("not an installer-managed shim"))).toBe(true);
expect(survivors).toEqual(shims);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

it("keeps managed CLI shims with --destroy-user-data when a confirmed sibling gateway remains (#9277)", () => {
const { home, shims } = makeHome("nemoclaw-uninstall-destroy-sibling-", []);
writeScopedGatewayState(home);

try {
const { result, logs, survivors } = uninstall(home, shims, {
destroyUserData: true,
run: okWithSiblingGatewayList,
});

expect(result.exitCode).toBe(0);
expect(logs).toContain(SCOPED_RETENTION_LOG);
expect(logs).not.toContain(DESTROY_SHIM_CLEANUP_LOG);
expect(logs).not.toContain(SCOPED_PACKAGE_RETENTION_LOG);
expect(survivors).toEqual(shims);
} finally {
fs.rmSync(home, { recursive: true, force: true });
Expand Down
Loading
Loading