diff --git a/docs/deployment/gateway-lifecycle-authority.mdx b/docs/deployment/gateway-lifecycle-authority.mdx index 89b6d862f2f..fb6c834d3e2 100644 --- a/docs/deployment/gateway-lifecycle-authority.mdx +++ b/docs/deployment/gateway-lifecycle-authority.mdx @@ -69,7 +69,7 @@ The declaration uses the following fields. | `version` | Contract version. Only `1` is supported. | | `mode` | `nemoclaw-managed` or `externally-supervised`. | | `endpoint` | Exact bare origin for an externally supervised gateway. Use `http` or `https`, host `127.0.0.1` or `[::1]`, and the gateway port that this NemoClaw process uses. Credentials, DNS names such as `localhost`, paths, query strings, and fragments are rejected. | -| `stateDir` | Absolute external gateway state directory. Keep it outside NemoClaw-owned state paths such as `~/.local/state/nemoclaw`; uninstall removes those owned paths and does not otherwise target the declared external directory. For HTTPS, it contains the client trust bundle. | +| `stateDir` | Absolute external gateway state directory. Keep it outside NemoClaw-owned state paths such as `~/.local/state/nemoclaw`. While the external gateway process remains, uninstall preserves the selected local gateway state and does not otherwise target the declared external directory. For HTTPS, it contains the client trust bundle. | | `supervisor` | Required for `externally-supervised` and rejected for `nemoclaw-managed`. `kind` is `systemd-system` or `systemd-user`. `serviceName` names one `.service` unit, and `execPath` is the absolute gateway executable path. | | `requiredCapabilities` | Capabilities onboarding needs. Supported values are `gateway.health`, `sandbox.create`, `sandbox.exec`, and `gpu.passthrough`. | diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index c8579ffd0c2..1cc05d7b8ea 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -20,7 +20,7 @@ $$nemoclaw uninstall | Flag | Effect | |---|---| | `--yes` | Skip the confirmation prompt. | -| `--keep-openshell` | Leave OpenShell binaries and NemoClaw-managed gateway service files in place, and do not stop the host gateway process. | +| `--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` | Also remove NemoClaw-pulled Ollama models. | | `--destroy-user-data` | Also remove preserved user data in `rebuild-backups/`, `backups/`, and `sandboxes.json`. | | `--gateway ` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT`. | @@ -31,7 +31,8 @@ Do not use `--gateway` to select another instance; when supplied for compatibili Default-port uninstall removes NemoClaw-managed entries in `openshell/gateway.env`. For a NemoClaw-managed authority, it also removes only NemoClaw's marked Linux gateway unit. It preserves upstream Linux package units, the macOS Homebrew service, and unrelated environment entries. -For an externally supervised authority, uninstall preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory. +For an externally supervised authority, uninstall preserves the local gateway state used by the running process in both full and gateway-scoped cleanup. +It also preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory. A custom-port uninstall does not stop or remove the default gateway service or its environment file. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 4ffa63e05be..52cf1dd02a4 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3369,14 +3369,16 @@ For Local Ollama setups, uninstall also stops matching Ollama auth proxy process For Hermes setups, uninstall inspects the selected gateway's managed port-forward watcher state, stops each verified watcher process and its sandbox-scoped forward, and leaves sibling gateway state untouched. If any watcher or forward cleanup cannot be confirmed, uninstall exits nonzero and preserves the selected gateway's watcher state so you can retry cleanup. -On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains NemoClaw-owned Docker-driver gateway SQLite data, audit logs, VM-driver state, and standalone-fallback gateway PID files. +On Linux, uninstall removes `~/.local/state/nemoclaw` unless you pass `--keep-openshell` or the gateway is externally supervised. +That directory contains NemoClaw-owned Docker-driver gateway configuration and SQLite data, audit logs, VM-driver state, and standalone-fallback gateway PID files. +Uninstall preserves it when the managed or externally supervised gateway process remains because that process depends on the state. Keep a declared external gateway state directory outside that NemoClaw-owned path. Uninstall does not otherwise target the declared external directory. | Flag | Effect | |---|---| | `--yes` | Skip the confirmation prompt | -| `--keep-openshell` | Leave OpenShell binaries and NemoClaw-managed gateway service files in place, and do not stop the host gateway process | +| `--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` | Also remove NemoClaw-pulled Ollama models | | `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`) | | `--gateway ` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT` | @@ -3392,7 +3394,8 @@ The compatibility `--gateway` flag cannot select another instance: when present, Default-port uninstall removes NemoClaw-managed entries in `openshell/gateway.env`. For a NemoClaw-managed authority, it also removes only NemoClaw's marked Linux gateway unit. It preserves upstream Linux package units, the macOS Homebrew service, and unrelated environment entries. -For an externally supervised authority, uninstall preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory. +For an externally supervised authority, uninstall preserves the selected local gateway state in both full and gateway-scoped cleanup. +It also preserves the gateway process, supervisor resources, marked Linux unit, Docker resources, OpenShell binaries, and the declared external state directory. A custom-port uninstall does not stop or remove the default gateway service or its environment file. ##### User-data preservation under `~/.nemoclaw/` diff --git a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts index c162510721b..245af303be7 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -55,11 +55,22 @@ describe("uninstall gateway-port segregation (#3053)", () => { ["full", "systemd-user"], ["scoped", "systemd-system"], ["scoped", "systemd-user"], - ] as const)("preserves the gateway process, Docker resources, and OpenShell binaries during %s uninstall for a %s-supervised gateway (#6576)", (scope, kind) => { + ] as const)("preserves the gateway process, Docker resources, OpenShell binaries, and gateway state during %s uninstall for a %s-supervised gateway (#6576)", (scope, kind) => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-external-")); try { const stateDir = path.join(tmpHome, ".nemoclaw"); + const gatewayStatePath = path.join( + tmpHome, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway", + "openshell-gateway.toml", + ); + const gatewayState = 'listen_address = "127.0.0.1:8080"\n'; fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(path.dirname(gatewayStatePath), { recursive: true }); + fs.writeFileSync(gatewayStatePath, gatewayState); const prepareScope = { full: () => undefined, scoped: () => @@ -129,6 +140,8 @@ describe("uninstall gateway-port segregation (#3053)", () => { command === "rm" && args.includes("/usr/local/bin/openshell-gateway"), ), ).toBe(false); + expect(fs.existsSync(gatewayStatePath)).toBe(true); + expect(fs.readFileSync(gatewayStatePath, "utf8")).toBe(gatewayState); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } diff --git a/src/lib/actions/uninstall/run-plan-gateway-service.test.ts b/src/lib/actions/uninstall/run-plan-gateway-service.test.ts index c6b38a1e6dd..382f76cc570 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-service.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-service.test.ts @@ -14,7 +14,7 @@ import { NEMOCLAW_OPENSHELL_GATEWAY_USER_SERVICE_MARKER, } from "../../onboard/docker-driver-gateway-service"; import { HOST_GATEWAY_PGREP_PATTERN } from "../../onboard/host-gateway-process"; -import { type RunResult, type UninstallRunDeps, runUninstallPlan } from "./run-plan"; +import { type RunResult, runUninstallPlan, type UninstallRunDeps } from "./run-plan"; function ok(stdout = ""): RunResult { return { status: 0, stdout, stderr: "" }; @@ -68,7 +68,26 @@ function writeGatewayEnv(test: Fixture, contents = "OPENSHELL_SERVER_PORT=8080\n return envPath; } -function uninstall(test: Fixture, keepOpenShell: boolean, deps: Partial = {}) { +function writeGatewayState(test: Fixture): string { + const configPath = path.join( + test.home, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway", + "openshell-gateway.toml", + ); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'listen_address = "127.0.0.1:8080"\n'); + return configPath; +} + +function uninstall( + test: Fixture, + keepOpenShell: boolean, + deps: Partial = {}, + gateways: { name: string }[] = [{ name: "nemoclaw" }], +) { const { commandExists = () => false, run = () => ok(), ...overrides } = deps; return runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell }, @@ -93,32 +112,83 @@ function uninstall(test: Fixture, keepOpenShell: boolean, deps: Partial command === "openshell" || commandExists(command), run: (command, args, options) => command === "openshell" && args[0] === "gateway" && args[1] === "list" - ? ok(JSON.stringify([{ name: "nemoclaw" }])) + ? ok(JSON.stringify(gateways)) : run(command, args, options), }, ); } describe("uninstall OpenShell gateway user service", () => { - it("keeps the service, env, and gateway process with --keep-openshell (#6903)", () => { + it("keeps the service, env, gateway process, and state with --keep-openshell (#7830)", () => { const test = fixture(true); const servicePath = writeManagedService(test); const envPath = writeGatewayEnv(test); + const gatewayStatePath = writeGatewayState(test); const run = vi.fn((_command: string, _args: string[]) => ok()); expect(uninstall(test, true, { commandExists: () => true, run }).exitCode).toBe(0); expect(fs.existsSync(servicePath)).toBe(true); expect(fs.existsSync(envPath)).toBe(true); + expect(fs.existsSync(gatewayStatePath)).toBe(true); expect(run.mock.calls.map(([, args]) => args)).not.toContainEqual([ "-f", HOST_GATEWAY_PGREP_PATTERN, ]); }); + it("keeps selected gateway state when sibling gateways require scoped cleanup (#7830)", () => { + const test = fixture(true); + const servicePath = writeManagedService(test); + const envPath = writeGatewayEnv(test); + const gatewayStatePath = writeGatewayState(test); + + const result = uninstall(test, true, { commandExists: () => true }, [ + { name: "nemoclaw" }, + { name: "sibling" }, + ]); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(servicePath)).toBe(true); + expect(fs.existsSync(envPath)).toBe(true); + expect(fs.existsSync(gatewayStatePath)).toBe(true); + }); + + it("keeps selected gateway state during scoped cleanup under external supervision (#6576)", () => { + const test = fixture(true); + const gatewayStatePath = writeGatewayState(test); + + const result = uninstall( + test, + false, + { + commandExists: () => true, + resolveGatewayTeardownAuthority: ({ gatewayName, gatewayPort }) => ({ + gatewayName, + gatewayPort, + mode: "externally-supervised", + source: "declared", + endpoint: `http://127.0.0.1:${String(gatewayPort)}`, + stateDir: path.dirname(gatewayStatePath), + supervisor: { + kind: "systemd-user", + serviceName: "external-openshell.service", + execPath: "/usr/local/bin/openshell-gateway", + }, + requiredCapabilities: [], + }), + }, + [{ name: "nemoclaw" }, { name: "sibling" }], + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(gatewayStatePath)).toBe(true); + }); + it("removes only the marked Linux unit and managed env on full uninstall (#6903)", () => { const test = fixture(true); const servicePath = writeManagedService(test); const envPath = writeGatewayEnv(test); + const gatewayStatePath = writeGatewayState(test); const calls: string[][] = []; const result = uninstall(test, false, { @@ -132,6 +202,7 @@ describe("uninstall OpenShell gateway user service", () => { expect(result.exitCode).toBe(0); expect(fs.existsSync(servicePath)).toBe(false); expect(fs.existsSync(envPath)).toBe(false); + expect(fs.existsSync(gatewayStatePath)).toBe(false); expect(calls).toContainEqual([ "systemctl", "--user", diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 0df806ecb82..c87a4d560e3 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1719,7 +1719,14 @@ function executePlan( ) { return { ok: false }; } - removePath(paths.selectedGatewayLocalStateDir, runtime); + if (!options.keepOpenShell && !externallySupervised) + removePath(paths.selectedGatewayLocalStateDir, runtime); + else + runtime.log( + externallySupervised + ? "Keeping OpenShell gateway configuration used by the externally supervised gateway." + : "Keeping OpenShell gateway configuration as requested.", + ); runtime.log( "Legacy sibling gateway rows remain; kept the shared default-root state for their recovery.", ); @@ -1748,16 +1755,28 @@ function executePlan( ) ok = false; if (scopedToSelectedGateway) { - removePath(paths.selectedGatewayLocalStateDir, runtime); + if (!options.keepOpenShell && !externallySupervised) + removePath(paths.selectedGatewayLocalStateDir, runtime); + else + runtime.log( + externallySupervised + ? "Keeping OpenShell gateway configuration used by the externally supervised gateway." + : "Keeping OpenShell gateway configuration as requested.", + ); if (GATEWAY_PORT === DEFAULT_GATEWAY_PORT && !options.keepOpenShell) { const envCleanup = removeNemoclawOpenShellGatewayEnv(paths, runtime); if (!envCleanup.ok) ok = false; } runtime.log("Sibling gateways remain; kept shared OpenShell and NemoClaw config."); } else { - removePath(paths.gatewayLocalStateDir, runtime); - if (options.keepOpenShell) - runtime.log("Keeping OpenShell gateway configuration as requested."); + if (!options.keepOpenShell && !externallySupervised) + removePath(paths.gatewayLocalStateDir, runtime); + if (options.keepOpenShell || externallySupervised) + runtime.log( + externallySupervised + ? "Keeping OpenShell gateway configuration used by the externally supervised gateway." + : "Keeping OpenShell gateway configuration as requested.", + ); else if (GATEWAY_PORT === DEFAULT_GATEWAY_PORT) { const envCleanup = removeNemoclawOpenShellGatewayEnv(paths, runtime); if (!envCleanup.ok) ok = false;