From 8d028b8d449e6d8d76c563db3fb01b501dc54301 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 12 Jul 2026 08:45:09 +0000 Subject: [PATCH 01/28] feat(state): segregate host state root per gateway port A non-default NEMOCLAW_GATEWAY_PORT now resolves its own host state root at ~/.nemoclaw/gateways//, covering the sandbox registry, credentials, snapshots, onboard session, and inference adapter state, so two gateways on one host stay fully segregated. The default port keeps the shared ~/.nemoclaw/ location, so existing single-gateway hosts are byte-identical. Route the registry, credentials, config-io, onboard, inference, share, and diagnostics paths through one nemoclawStateRoot resolver keyed off GATEWAY_PORT. Keep the CLI source clone and the managed_swap marker host-shared. Uninstall targets the port-scoped state root, always preserves the gateways/ subtree so other environments survive even --destroy-user-data, and no longer tears down the host-shared swap file while other gateway-port environments remain. Fixes #3053 Signed-off-by: Tinson Lai --- docs/manage-sandboxes/lifecycle.mdx | 2 + docs/reference/architecture.mdx | 1 + .../run-plan-gateway-segregation.test.ts | 87 +++++++++++++++++++ src/lib/actions/uninstall/run-plan.ts | 69 ++++++++++++--- src/lib/credentials/store.ts | 5 +- src/lib/domain/uninstall/paths.ts | 4 +- src/lib/inference/local-adapter-lifecycle.ts | 5 +- src/lib/inference/local.ts | 3 +- src/lib/onboard/docker-gpu-patch.ts | 4 +- src/lib/onboard/model-router.ts | 5 +- src/lib/onboard/sandbox-create-failure.ts | 5 +- src/lib/onboard/usage-notice.ts | 3 +- src/lib/share-command.ts | 6 +- src/lib/state/config-io.ts | 8 +- src/lib/state/onboard-session.ts | 3 +- src/lib/state/paths.ts | 4 +- .../state/registry-port-segregation.test.ts | 40 +++++++++ src/lib/state/registry.ts | 6 +- src/lib/state/sandbox.ts | 10 ++- src/lib/state/state-root.test.ts | 41 +++++++++ src/lib/state/state-root.ts | 25 ++++++ 21 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts create mode 100644 src/lib/state/registry-port-segregation.test.ts create mode 100644 src/lib/state/state-root.test.ts create mode 100644 src/lib/state/state-root.ts diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index c5de28ce253..e721bd89d4d 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -147,12 +147,14 @@ When the default API port is already held by another sandbox, `$$nemoclaw onboar If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so two gateways on one host stay fully segregated. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. Gateway and dashboard cleanup is scoped by sandbox name and port. A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward. If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so two gateways on one host stay fully segregated. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. Gateway cleanup is scoped by sandbox name and port. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 1401afd46b9..9aa441c8212 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -308,6 +308,7 @@ NemoClaw keeps non-secret operator-facing state on the host rather than inside t | Path | Purpose | |---|---| | `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata, including the default sandbox selection. | +| `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, credentials, and snapshots) for a non-default `NEMOCLAW_GATEWAY_PORT`. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | | `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | diff --git a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts new file mode 100644 index 00000000000..32927c1da34 --- /dev/null +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { type RunResult, runUninstallPlan } from "./run-plan"; + +function ok(stdout = ""): RunResult { + return { status: 0, stdout, stderr: "" }; +} + +describe("uninstall gateway-port segregation (#3053)", () => { + it("preserves the gateways/ subtree so uninstalling one environment leaves the others", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-gwpreserve-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + const otherEnv = path.join(stateDir, "gateways", "8091"); + fs.mkdirSync(otherEnv, { recursive: true }); + fs.writeFileSync(path.join(otherEnv, "sandboxes.json"), "[]"); + fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + const logs: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: () => false, + env: { + HOME: tmpHome, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1", + } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: (line) => logs.push(line), + run: vi.fn(() => ok()), + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(fs.existsSync(path.join(otherEnv, "sandboxes.json"))).toBe(true); + expect(fs.existsSync(path.join(stateDir, "sandboxes.json"))).toBe(false); + expect(fs.existsSync(stateDir)).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps the host-shared /swapfile when other gateway-port environments remain", () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-swap-")); + try { + const stateDir = path.join(tmpHome, ".nemoclaw"); + fs.mkdirSync(path.join(stateDir, "gateways", "8091"), { recursive: true }); + fs.writeFileSync(path.join(stateDir, "managed_swap"), "/swapfile"); + const logs: string[] = []; + const swapCommands: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "" } as NodeJS.ProcessEnv, + existsSync: (target) => + target === "/swapfile" || (target.startsWith(tmpHome) && fs.existsSync(target)), + isTty: true, + log: (line) => logs.push(line), + rmSync: vi.fn(), + run: (_command, args) => { + if (args[0] === "swapoff") swapCommands.push("swapoff"); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(logs).toContain( + "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", + ); + expect(swapCommands).not.toContain("swapoff"); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index 282b60394a3..f7335762618 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -10,22 +10,23 @@ import { dockerSpawnSync } from "../../adapters/docker/exec"; import { type AgentBranding, getAgentBranding } from "../../cli/branding"; import { isStdinTty, readLineFromStdin } from "../../core/stdin"; import { sleepMs } from "../../core/wait"; -import { - defaultUninstallPaths, - NEMOCLAW_OLLAMA_MODELS, - NEMOCLAW_PROVIDERS, - type UninstallPaths, -} from "../../domain/uninstall/paths"; import { gatewayDestroySkipMessage, OPENSHELL_SANDBOXES_DELETE_SKIP_MESSAGE, preservedRegistryUnrecoverableWarnings, providerDeleteSkipMessage, } from "../../domain/uninstall/messaging"; +import { + defaultUninstallPaths, + NEMOCLAW_OLLAMA_MODELS, + NEMOCLAW_PROVIDERS, + type UninstallPaths, +} from "../../domain/uninstall/paths"; import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan"; import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process"; import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup"; +import { GATEWAYS_SUBDIR } from "../../state/state-root"; import { stopOpenRouterRuntimeAdapter } from "./openrouter-runtime-adapter-cleanup"; import { classifyShimPath, type FileSystemDeps } from "./plan"; @@ -289,23 +290,38 @@ function printBye(runtime: UninstallRuntime): void { runtime.log(branding.uninstallGoodbye); } -function userDataDispositionLine(options: UninstallRunOptions, runtime: UninstallRuntime): string { +function stateDirDisplay(paths: UninstallPaths): string { + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + const home = path.dirname(sharedRoot); + return `~/${path.relative(home, paths.nemoclawStateDir)}`; +} + +function userDataDispositionLine( + options: UninstallRunOptions, + runtime: UninstallRuntime, + paths: UninstallPaths, +): string { + const dir = stateDirDisplay(paths); if (options.destroyUserData) { - return " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)"; + return ` · ${dir} (removes rebuild-backups/, backups/, sandboxes.json: --destroy-user-data set)`; } if (runtime.env.NEMOCLAW_UNINSTALL_DESTROY_USER_DATA === "1") { - return " · ~/.nemoclaw (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)"; + return ` · ${dir} (removes rebuild-backups/, backups/, sandboxes.json: NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1)`; } - return " · ~/.nemoclaw (preserves rebuild-backups/, backups/, sandboxes.json by default)"; + return ` · ${dir} (preserves rebuild-backups/, backups/, sandboxes.json by default)`; } -function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boolean { +function confirm( + options: UninstallRunOptions, + runtime: UninstallRuntime, + paths: UninstallPaths, +): boolean { const branding = runtimeBranding(runtime); if (options.assumeYes) return true; runtime.log("What will be removed:"); runtime.log(` · All OpenShell sandboxes, gateway, and ${branding.display} providers`); runtime.log(" · Related Docker containers, images, and volumes"); - runtime.log(userDataDispositionLine(options, runtime)); + runtime.log(userDataDispositionLine(options, runtime, paths)); runtime.log(" · ~/.config/openshell ~/.config/nemoclaw"); runtime.log(` · Global ${branding.display} CLI (npm package: nemoclaw)`); runtime.log( @@ -793,6 +809,18 @@ function removeOllamaModels(options: UninstallRunOptions, runtime: UninstallRunt } } +function otherGatewayEnvironmentsRemain(paths: UninstallPaths, runtime: UninstallRuntime): boolean { + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + if (paths.nemoclawStateDir !== sharedRoot) return true; + const gatewaysDir = path.join(sharedRoot, "gateways"); + if (!runtime.existsSync(gatewaysDir)) return false; + try { + return fs.readdirSync(gatewaysDir).length > 0; + } catch { + return false; + } +} + function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): void { if (!runtime.existsSync("/swapfile")) { runtime.log("No /swapfile found; skipping swap cleanup."); @@ -804,6 +832,12 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo ); return; } + if (otherGatewayEnvironmentsRemain(paths, runtime)) { + runtime.log( + "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", + ); + return; + } if (runtime.env.NEMOCLAW_NON_INTERACTIVE === "1" || !runtime.isTty) { runtime.warn("Skipping swap cleanup in non-interactive mode (requires sudo)."); return; @@ -943,7 +977,14 @@ function executePlan( else for (const target of paths.openshellInstallPaths) removeFileWithOptionalSudo(target, runtime); - if (!removePathExcept(paths.nemoclawStateDir, preserveUnderStateDir, runtime)) ok = false; + if ( + !removePathExcept( + paths.nemoclawStateDir, + [...preserveUnderStateDir, GATEWAYS_SUBDIR], + runtime, + ) + ) + ok = false; removePath(paths.gatewayLocalStateDir, runtime); removePath(paths.openshellConfigDir, runtime); removePath(paths.nemoclawConfigDir, runtime); @@ -980,7 +1021,7 @@ export function runUninstallPlan( const runtime = buildRuntime(deps); const { paths, plan } = buildRunPlan(options, { ...deps, env: runtime.env }); printBanner(runtime); - if (!confirm(options, runtime)) return { exitCode: 0, plan }; + if (!confirm(options, runtime, paths)) return { exitCode: 0, plan }; const preserveUnderStateDir = resolvePreserveSet(paths, options, runtime); const { ok } = executePlan(plan, paths, options, runtime, preserveUnderStateDir); if (ok) { diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 2c22c85d703..f65f7f30094 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -17,6 +17,7 @@ import { isErrnoException } from "../core/errno"; import { createPromptActivityCleanup } from "../core/prompt-activity"; import { listMessagingCredentialMetadata } from "../messaging/channels"; import { rejectSymlinksOnPath } from "../state/config-io"; +import { nemoclawStateRoot } from "../state/state-root"; const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); @@ -127,10 +128,10 @@ export function getCredsDir(): string { const home = resolveHomeDir(); if (_cachedHome !== home) { _cachedHome = home; - _credsDir = path.join(home, ".nemoclaw"); + _credsDir = nemoclawStateRoot(home); _legacyCredsFile = null; } - return _credsDir || path.join(home, ".nemoclaw"); + return _credsDir || nemoclawStateRoot(home); } /** diff --git a/src/lib/domain/uninstall/paths.ts b/src/lib/domain/uninstall/paths.ts index 3d4149430fa..aa2819794c4 100644 --- a/src/lib/domain/uninstall/paths.ts +++ b/src/lib/domain/uninstall/paths.ts @@ -3,6 +3,8 @@ import path from "node:path"; +import { nemoclawStateRoot } from "../../state/state-root"; + export const DEFAULT_GATEWAY_NAME = "nemoclaw"; export const NEMOCLAW_PROVIDERS = [ "nvidia-nim", @@ -68,7 +70,7 @@ export function defaultUninstallPaths(options: UninstallPathOptions): UninstallP binName, path: path.join(options.home, ".local", "bin", binName), })), - nemoclawStateDir: path.join(options.home, ".nemoclaw"), + nemoclawStateDir: nemoclawStateRoot(options.home), gatewayLocalStateDir: path.join(options.home, ".local", "state", "nemoclaw"), openshellConfigDir: path.join(options.home, ".config", "openshell"), openshellInstallPaths: openshellInstallPathsForBinDirs(["/usr/local/bin", xdgBinHome]), diff --git a/src/lib/inference/local-adapter-lifecycle.ts b/src/lib/inference/local-adapter-lifecycle.ts index 5c028dc1d0d..e012e24c74f 100644 --- a/src/lib/inference/local-adapter-lifecycle.ts +++ b/src/lib/inference/local-adapter-lifecycle.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import http from "node:http"; @@ -9,6 +9,7 @@ import os from "node:os"; import path from "node:path"; import { waitUntilAsync } from "../core/wait"; +import { nemoclawStateRoot } from "../state/state-root"; export type JsonObject = Record; @@ -22,7 +23,7 @@ export type RunFn = ( options?: { ignoreError?: boolean; suppressOutput?: boolean }, ) => unknown; -export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = path.join(os.homedir(), ".nemoclaw"); +export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = nemoclawStateRoot(os.homedir()); export function ensureLocalAdapterStateDir(stateDir = DEFAULT_LOCAL_ADAPTER_STATE_DIR): void { if (!fs.existsSync(stateDir)) { diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 3f839927005..cd965d4f1d9 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -18,6 +18,7 @@ import { OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports"; import { sleepSeconds } from "../core/wait"; import { containerCanReachHostLoopback, isWsl } from "../platform"; import { type CaptureResult, runCapture, runCaptureEx, shellQuote } from "../runner"; +import { nemoclawStateRoot } from "../state/state-root"; import { buildSubprocessEnv } from "../subprocess-env"; import { detectNvidiaPlatform } from "./nim"; import { @@ -244,7 +245,7 @@ export interface LocalProviderHealthProbeOptions { } function defaultLoadOllamaProxyToken(): string | null { - const tokenPath = nodePath.join(os.homedir(), ".nemoclaw", "ollama-proxy-token"); + const tokenPath = nodePath.join(nemoclawStateRoot(os.homedir()), "ollama-proxy-token"); try { if (fs.existsSync(tokenPath)) { const token = fs.readFileSync(tokenPath, "utf-8").trim(); diff --git a/src/lib/onboard/docker-gpu-patch.ts b/src/lib/onboard/docker-gpu-patch.ts index 6dc3c7ac8c2..dfd90e1df00 100644 --- a/src/lib/onboard/docker-gpu-patch.ts +++ b/src/lib/onboard/docker-gpu-patch.ts @@ -15,6 +15,7 @@ import { dockerStart, dockerStop, } from "../adapters/docker"; +import { nemoclawStateRoot } from "../state/state-root"; import { createDockerGpuDiagnosticRedactor } from "./docker-gpu-diagnostic-redaction"; import { reconcileSupervisorReconnect, @@ -1814,8 +1815,7 @@ export function collectDockerGpuPatchDiagnostics( const d = depsWithDefaults(deps); const now = d.now(); const dir = path.join( - d.homedir(), - ".nemoclaw", + nemoclawStateRoot(d.homedir()), "onboard-failures", `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}-docker-gpu-patch`, ); diff --git a/src/lib/onboard/model-router.ts b/src/lib/onboard/model-router.ts index a40afe0cc65..6f4d707187c 100644 --- a/src/lib/onboard/model-router.ts +++ b/src/lib/onboard/model-router.ts @@ -15,6 +15,7 @@ import { ROOT, run, runCapture } from "../runner"; import { hashCredential } from "../security/credential-hash"; import type { Session } from "../state/onboard-session"; import * as onboardSession from "../state/onboard-session"; +import { nemoclawStateRoot } from "../state/state-root"; import { buildSubprocessEnv } from "../subprocess-env"; import { hydrateCredentialEnv } from "./credential-env"; import { @@ -40,7 +41,7 @@ export { const ROUTER_HEALTH_RETRIES = 15; const ROUTER_HEALTH_INTERVAL_MS = 2000; const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router"); -const MODEL_ROUTER_VENV_DIR = path.join(os.homedir(), ".nemoclaw", "model-router-venv"); +const MODEL_ROUTER_VENV_DIR = path.join(nemoclawStateRoot(os.homedir()), "model-router-venv"); export const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_INFERENCE_API_KEY"; export type BlueprintRouterConfig = { @@ -224,7 +225,7 @@ export async function startModelRouter( blueprintDir, routerCfg.pool_config_path || "router/pool-config.yaml", ); - const stateDir = path.join(deps.homeDir, ".nemoclaw", "state"); + const stateDir = path.join(nemoclawStateRoot(deps.homeDir), "state"); const litellmConfigPath = path.join(stateDir, "litellm-proxy.yaml"); deps.mkdirSync(stateDir); diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index c05df67492b..ee44b960a51 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -5,6 +5,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { nemoclawStateRoot } from "../state/state-root"; + const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; const MAX_RELEVANT_LOG_LINES = 120; @@ -153,8 +155,7 @@ export function collectSandboxCreateFailureDiagnostics( const homeDir = options.homeDir ?? os.homedir(); const now = options.now ?? new Date(); const dir = path.join( - homeDir, - ".nemoclaw", + nemoclawStateRoot(homeDir), "onboard-failures", `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}`, ); diff --git a/src/lib/onboard/usage-notice.ts b/src/lib/onboard/usage-notice.ts index 421c9b0dacf..d3ae22d0570 100644 --- a/src/lib/onboard/usage-notice.ts +++ b/src/lib/onboard/usage-notice.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import noticeConfig from "../../../bin/lib/usage-notice.json"; +import { nemoclawStateRoot } from "../state/state-root"; export const NOTICE_ACCEPT_FLAG_NAME = "yes-i-accept-third-party-software"; export const NOTICE_ACCEPT_FLAG = `--${NOTICE_ACCEPT_FLAG_NAME}`; @@ -52,7 +53,7 @@ function parseJson(text: string): T { } export function getUsageNoticeStateFile(): string { - return path.join(process.env.HOME || os.homedir(), ".nemoclaw", "usage-notice.json"); + return path.join(nemoclawStateRoot(process.env.HOME || os.homedir()), "usage-notice.json"); } export function loadUsageNoticeConfig(): NoticeConfig { diff --git a/src/lib/share-command.ts b/src/lib/share-command.ts index 0e03abd9695..2c5fafa7f59 100644 --- a/src/lib/share-command.ts +++ b/src/lib/share-command.ts @@ -13,9 +13,9 @@ import { spawnSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; - -import { buildShareCommandDeps } from "./share-command-deps"; import type { ShareCommandDeps } from "./share-command-deps"; +import { buildShareCommandDeps } from "./share-command-deps"; +import { nemoclawStateRoot } from "./state/state-root"; export class ShareCommandError extends Error { readonly lines: readonly string[]; @@ -61,7 +61,7 @@ export function isMountPoint(dir: string): boolean { } export function defaultShareMountDir(sandboxName: string): string { - return path.join(process.env.HOME || os.homedir(), ".nemoclaw", "mounts", sandboxName); + return path.join(nemoclawStateRoot(process.env.HOME || os.homedir()), "mounts", sandboxName); } /** diff --git a/src/lib/state/config-io.ts b/src/lib/state/config-io.ts index e0a4cae76c2..76ab2db00de 100644 --- a/src/lib/state/config-io.ts +++ b/src/lib/state/config-io.ts @@ -6,9 +6,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; - -import { shellQuote } from "../core/shell-quote"; import { isErrnoException, isPermissionError } from "../core/errno"; +import { shellQuote } from "../core/shell-quote"; +import { nemoclawStateRoot } from "./state-root"; // Strict JSON types for file serialization — unlike json-types.ts, // these exclude undefined since actual JSON cannot contain it. @@ -26,7 +26,7 @@ type SerializableConfig = JsonScalar | JsonValue[] | object; // heal opt out cleanly when a caller routes a sandbox-internal path here. function hostNemoclawDir(): string { const home = process.env.HOME ?? os.homedir(); - return path.resolve(home, ".nemoclaw"); + return path.resolve(nemoclawStateRoot(home)); } function isHostNemoclawRoot(dirPath: string): boolean { @@ -61,7 +61,7 @@ function cleanupTempFile(filePath: string): void { function buildRemediation(): string { const home = process.env.HOME ?? os.homedir(); - const nemoclawDir = path.join(home, ".nemoclaw"); + const nemoclawDir = nemoclawStateRoot(home); const backupDir = `${nemoclawDir}.backup.${String(process.pid)}`; const recoveryHome = path.join( os.tmpdir(), diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 91005a70813..2b88b853671 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -41,10 +41,11 @@ import { shouldUpdateMachine, } from "./onboard-step-mutation"; import { nextMachineStateAfterCompletedStep } from "./onboard-step-state"; +import { nemoclawStateRoot } from "./state-root"; export const SESSION_VERSION = 1; export const MACHINE_SNAPSHOT_VERSION = 1; -export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); +export const SESSION_DIR = nemoclawStateRoot(process.env.HOME || "/tmp"); export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); diff --git a/src/lib/state/paths.ts b/src/lib/state/paths.ts index e92b72bac44..21397151039 100644 --- a/src/lib/state/paths.ts +++ b/src/lib/state/paths.ts @@ -4,11 +4,13 @@ import os from "node:os"; import path from "node:path"; +import { nemoclawStateRoot } from "./state-root"; + export const ROOT = path.resolve(__dirname, "..", "..", ".."); export const SCRIPTS = path.join(ROOT, "scripts"); export function resolveNemoclawHomeDir(homeDir: string = process.env.HOME ?? os.homedir()): string { - return path.join(homeDir, ".nemoclaw"); + return nemoclawStateRoot(homeDir); } export function resolveNemoclawStateDir( diff --git a/src/lib/state/registry-port-segregation.test.ts b/src/lib/state/registry-port-segregation.test.ts new file mode 100644 index 00000000000..5fa1a396957 --- /dev/null +++ b/src/lib/state/registry-port-segregation.test.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const HOME = "/home/wiring"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function importRegistryFile(gatewayPort: string): Promise { + vi.resetModules(); + vi.stubEnv("HOME", HOME); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", gatewayPort); + const mod = await import("./registry"); + return mod.REGISTRY_FILE; +} + +describe("registry path segregation by gateway port (#3053)", () => { + it("keeps the default gateway port on the shared ~/.nemoclaw/sandboxes.json", async () => { + const file = await importRegistryFile(""); + expect(file).toBe(path.join(HOME, ".nemoclaw", "sandboxes.json")); + }); + + it("segregates a non-default gateway port into its own registry", async () => { + const file = await importRegistryFile("9123"); + expect(file).toBe(path.join(HOME, ".nemoclaw", "gateways", "9123", "sandboxes.json")); + }); + + it("gives two non-default ports non-colliding registries", async () => { + const a = await importRegistryFile("9123"); + const b = await importRegistryFile("9124"); + expect(a).not.toBe(b); + expect(b).toBe(path.join(HOME, ".nemoclaw", "gateways", "9124", "sandboxes.json")); + }); +}); diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 8f7108c69f3..fe3a4d19dee 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -28,6 +28,7 @@ import { import type { SandboxMessagingState } from "./registry-messaging"; import { parseSandboxRegistryEntries, retainedDefaultSandbox } from "./registry-normalization"; import * as reversibleRemoval from "./registry-reversible-removal"; +import { nemoclawStateRoot } from "./state-root"; export { getSandboxEntryDisplayInference, @@ -161,7 +162,10 @@ export interface SandboxRegistry { export type SandboxRemovalReceipt = reversibleRemoval.RegistryRemovalReceipt; -export const REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json"); +export const REGISTRY_FILE = path.join( + nemoclawStateRoot(process.env.HOME || "/tmp"), + "sandboxes.json", +); export const LOCK_DIR = `${REGISTRY_FILE}.lock`; export const LOCK_OWNER = path.join(LOCK_DIR, "owner"); export const LOCK_STALE_MS = 10_000; diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 09aa0c7d5e7..e7a252825ee 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -54,10 +54,11 @@ import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { isSshTransportFailure } from "./ssh-transport.js"; import { restoreStateFile } from "./state-file-restore.js"; +import { nemoclawStateRoot } from "./state-root.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); -const REBUILD_BACKUPS_DIR = path.join(HOME_DIR, ".nemoclaw", "rebuild-backups"); +const REBUILD_BACKUPS_DIR = path.join(nemoclawStateRoot(HOME_DIR), "rebuild-backups"); const MANIFEST_VERSION = 1; export const OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR = @@ -577,7 +578,12 @@ export function sshArgs(configFile: string, sandboxName: string): string[] { function computeBlueprintDigest(): string | null { // Look for blueprint.yaml relative to the agent-defs ROOT const candidates = [ - path.join(process.env.HOME || "/tmp", ".nemoclaw", "blueprints", "0.1.0", "blueprint.yaml"), + path.join( + nemoclawStateRoot(process.env.HOME || "/tmp"), + "blueprints", + "0.1.0", + "blueprint.yaml", + ), path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"), ]; for (const p of candidates) { diff --git a/src/lib/state/state-root.test.ts b/src/lib/state/state-root.test.ts new file mode 100644 index 00000000000..b7bf3d5ca40 --- /dev/null +++ b/src/lib/state/state-root.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { DEFAULT_GATEWAY_PORT } from "../core/ports"; +import { GATEWAYS_SUBDIR, getNemoclawStateRoot, nemoclawStateRoot } from "./state-root"; + +const HOME = "/home/alice"; + +describe("nemoclawStateRoot", () => { + it("resolves the default gateway port to ~/.nemoclaw byte-identically (#3053)", () => { + expect(nemoclawStateRoot(HOME, DEFAULT_GATEWAY_PORT)).toBe(path.join(HOME, ".nemoclaw")); + }); + + it("segregates a non-default gateway port under ~/.nemoclaw/gateways/ (#3053)", () => { + expect(nemoclawStateRoot(HOME, 8091)).toBe( + path.join(HOME, ".nemoclaw", GATEWAYS_SUBDIR, "8091"), + ); + expect(nemoclawStateRoot(HOME, 9000)).toBe( + path.join(HOME, ".nemoclaw", GATEWAYS_SUBDIR, "9000"), + ); + }); + + it("keeps each segregated root distinct and beneath the shared default root (#3053)", () => { + const shared = nemoclawStateRoot(HOME, DEFAULT_GATEWAY_PORT); + const portA = nemoclawStateRoot(HOME, 8091); + const portB = nemoclawStateRoot(HOME, 8092); + expect(portA).not.toBe(portB); + expect(portA.startsWith(`${shared}${path.sep}`)).toBe(true); + expect(portB.startsWith(`${shared}${path.sep}`)).toBe(true); + }); +}); + +describe("getNemoclawStateRoot", () => { + it("defaults to the shared root when NEMOCLAW_GATEWAY_PORT is unset (#3053)", () => { + expect(getNemoclawStateRoot(HOME)).toBe(path.join(HOME, ".nemoclaw")); + }); +}); diff --git a/src/lib/state/state-root.ts b/src/lib/state/state-root.ts new file mode 100644 index 00000000000..7dc1a2dc72e --- /dev/null +++ b/src/lib/state/state-root.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import os from "node:os"; +import path from "node:path"; + +import { DEFAULT_GATEWAY_PORT, GATEWAY_PORT } from "../core/ports"; + +export const STATE_DIR_NAME = ".nemoclaw"; +export const GATEWAYS_SUBDIR = "gateways"; + +export function nemoclawStateRoot(home: string, gatewayPort: number = GATEWAY_PORT): string { + const base = path.join(home, STATE_DIR_NAME); + return gatewayPort === DEFAULT_GATEWAY_PORT + ? base + : path.join(base, GATEWAYS_SUBDIR, String(gatewayPort)); +} + +export function resolveHome(env: NodeJS.ProcessEnv = process.env): string { + return env.HOME ?? os.homedir(); +} + +export function getNemoclawStateRoot(home: string = resolveHome()): string { + return nemoclawStateRoot(home, GATEWAY_PORT); +} From 15bbe12ee50192807262af51915c85905f8d8459 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 12 Jul 2026 10:18:12 +0000 Subject: [PATCH 02/28] fix(state): keep gateway-port state root correct under real CLI runs Fixes a regression the port-keyed state root introduced in a pre-existing CLI status test that temporarily overrides NEMOCLAW_GATEWAY_PORT to probe for a gateway-port conflict, closes the empty-HOME relative-path edge case in the state-root resolver, makes the default-port unit test control the environment variable it claims to test, corrects two doc wording issues (an overstated segregation claim and a Deep Agents command name), and drops a conditional from the new uninstall test to satisfy the codebase-growth guardrail. Signed-off-by: Tinson Lai --- docs/manage-sandboxes/lifecycle.mdx | 4 +- docs/reference/architecture.mdx | 2 +- .../run-plan-gateway-segregation.test.ts | 6 +-- src/lib/state/state-root.test.ts | 16 ++++++-- src/lib/state/state-root.ts | 2 +- test/repro-2666-silent-list-status.test.ts | 41 +++++++++++-------- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index e721bd89d4d..29c2c3117eb 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -147,14 +147,14 @@ When the default API port is already held by another sandbox, `$$nemoclaw onboar If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so two gateways on one host stay fully segregated. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. Gateway and dashboard cleanup is scoped by sandbox name and port. A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward. If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so two gateways on one host stay fully segregated. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. The default port keeps the shared `~/.nemoclaw/` location, and `nemo-deepagents uninstall` on one port leaves the other ports' state in place. Gateway cleanup is scoped by sandbox name and port. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 9aa441c8212..d18fd0c852d 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -307,7 +307,7 @@ NemoClaw keeps non-secret operator-facing state on the host rather than inside t | Path | Purpose | |---|---| -| `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata, including the default sandbox selection. | +| `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata for the default gateway port, including the default sandbox selection. | | `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, credentials, and snapshots) for a non-default `NEMOCLAW_GATEWAY_PORT`. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | | `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | 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 32927c1da34..11bf59c1e30 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -56,7 +56,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { fs.mkdirSync(path.join(stateDir, "gateways", "8091"), { recursive: true }); fs.writeFileSync(path.join(stateDir, "managed_swap"), "/swapfile"); const logs: string[] = []; - const swapCommands: string[] = []; + const runCalls: string[][] = []; const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { @@ -68,7 +68,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (_command, args) => { - if (args[0] === "swapoff") swapCommands.push("swapoff"); + runCalls.push(args); return ok(); }, runDocker: () => ok(""), @@ -79,7 +79,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { expect(logs).toContain( "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", ); - expect(swapCommands).not.toContain("swapoff"); + expect(runCalls.some((args) => args[0] === "swapoff")).toBe(false); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } diff --git a/src/lib/state/state-root.test.ts b/src/lib/state/state-root.test.ts index b7bf3d5ca40..1db2a369cae 100644 --- a/src/lib/state/state-root.test.ts +++ b/src/lib/state/state-root.test.ts @@ -3,10 +3,10 @@ import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_GATEWAY_PORT } from "../core/ports"; -import { GATEWAYS_SUBDIR, getNemoclawStateRoot, nemoclawStateRoot } from "./state-root"; +import { GATEWAYS_SUBDIR, nemoclawStateRoot } from "./state-root"; const HOME = "/home/alice"; @@ -35,7 +35,15 @@ describe("nemoclawStateRoot", () => { }); describe("getNemoclawStateRoot", () => { - it("defaults to the shared root when NEMOCLAW_GATEWAY_PORT is unset (#3053)", () => { - expect(getNemoclawStateRoot(HOME)).toBe(path.join(HOME, ".nemoclaw")); + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("defaults to the shared root when NEMOCLAW_GATEWAY_PORT is unset (#3053)", async () => { + vi.resetModules(); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", ""); + const { getNemoclawStateRoot: freshGetNemoclawStateRoot } = await import("./state-root"); + expect(freshGetNemoclawStateRoot(HOME)).toBe(path.join(HOME, ".nemoclaw")); }); }); diff --git a/src/lib/state/state-root.ts b/src/lib/state/state-root.ts index 7dc1a2dc72e..b1b8ae88978 100644 --- a/src/lib/state/state-root.ts +++ b/src/lib/state/state-root.ts @@ -17,7 +17,7 @@ export function nemoclawStateRoot(home: string, gatewayPort: number = GATEWAY_PO } export function resolveHome(env: NodeJS.ProcessEnv = process.env): string { - return env.HOME ?? os.homedir(); + return env.HOME || os.homedir(); } export function getNemoclawStateRoot(home: string = resolveHome()): string { diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index 5a8a0251f05..8384bcba1d2 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -30,6 +30,7 @@ import { renderSandboxInventoryText, } from "../src/lib/inventory/index.js"; import { recoverRegistryEntriesWithFallback } from "../src/lib/list-command-deps.js"; +import { nemoclawStateRoot } from "../src/lib/state/state-root.js"; import { testTimeoutOptions } from "./helpers/timeouts"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); @@ -206,24 +207,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress { mode: 0o755 }, ); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - "my-assist": { - name: "my-assist", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, - }, - defaultSandbox: "my-assist", - }), - { mode: 0o600 }, - ); + seedRegistry(path.join(home, ".nemoclaw")); }); afterEach(() => { @@ -259,6 +243,26 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress fs.writeFileSync(path.join(binDir, "openshell"), lines.join("\n"), { mode: 0o755 }); } + function seedRegistry(stateDir: string): void { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + "my-assist": { + name: "my-assist", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "my-assist", + }), + { mode: 0o600 }, + ); + } + function expectLayerBefore(combined: string, layer: string, laterText: string): void { const layerIndex = combined.indexOf(`Failure layer: ${layer}`); const laterIndex = combined.indexOf(laterText); @@ -379,6 +383,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress const listener = net.createServer(); await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); const port = (listener.address() as { port: number }).port; + seedRegistry(nemoclawStateRoot(home, port)); try { // Fake docker: info OK, ps shows nothing running, ps -a shows the From 435c6893064dce42cbcbd744c2311671af38cfbc Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 12 Jul 2026 11:52:46 +0000 Subject: [PATCH 03/28] test(cli): prove port segregation in the gateway-conflict status test The port-specific #3271 scenario seeded identical my-assist data under both the default and port-scoped registries, so a regression that kept reading the default root under a non-default NEMOCLAW_GATEWAY_PORT would still pass. Clear the default registry before that scenario so the test only succeeds when the CLI actually reads the port-scoped root. Signed-off-by: Tinson Lai --- test/repro-2666-silent-list-status.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index 8384bcba1d2..22e206aeef5 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -383,6 +383,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress const listener = net.createServer(); await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); const port = (listener.address() as { port: number }).port; + fs.rmSync(path.join(home, ".nemoclaw", "sandboxes.json"), { force: true }); seedRegistry(nemoclawStateRoot(home, port)); try { From 4f56d3932cbb1d9e4f878bcf84b7425bbd0b6cf5 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 13 Jul 2026 05:17:07 +0000 Subject: [PATCH 04/28] refactor(state): pass GATEWAY_PORT explicitly to nemoclawStateRoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every production call site relied on nemoclawStateRoot's default gatewayPort parameter, which resolves to the correct value only because GATEWAY_PORT happens to be a process-wide constant computed once at module load. That's easy to misread as always resolving the default port. Thread GATEWAY_PORT through all 18 call sites explicitly so each one is self-evidently correct without tracing into core/ports.ts. No behavioural change — GATEWAY_PORT is the exact value the default parameter already supplied. Signed-off-by: Tinson Lai --- src/lib/credentials/store.ts | 5 +++-- src/lib/domain/uninstall/paths.ts | 3 ++- src/lib/inference/local-adapter-lifecycle.ts | 3 ++- src/lib/inference/local.ts | 7 +++++-- src/lib/onboard/docker-gpu-patch-diagnostics.ts | 3 ++- src/lib/onboard/model-router.ts | 8 ++++++-- src/lib/onboard/sandbox-create-failure.ts | 3 ++- src/lib/onboard/usage-notice.ts | 6 +++++- src/lib/share-command.ts | 7 ++++++- src/lib/state/config-io.ts | 5 +++-- src/lib/state/onboard-session.ts | 3 ++- src/lib/state/paths.ts | 3 ++- src/lib/state/registry.ts | 3 ++- src/lib/state/sandbox.ts | 5 +++-- 14 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index f65f7f30094..5f4ee38d1b1 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,6 +14,7 @@ import path from "node:path"; import readline from "node:readline"; import { isErrnoException } from "../core/errno"; +import { GATEWAY_PORT } from "../core/ports"; import { createPromptActivityCleanup } from "../core/prompt-activity"; import { listMessagingCredentialMetadata } from "../messaging/channels"; import { rejectSymlinksOnPath } from "../state/config-io"; @@ -128,10 +129,10 @@ export function getCredsDir(): string { const home = resolveHomeDir(); if (_cachedHome !== home) { _cachedHome = home; - _credsDir = nemoclawStateRoot(home); + _credsDir = nemoclawStateRoot(home, GATEWAY_PORT); _legacyCredsFile = null; } - return _credsDir || nemoclawStateRoot(home); + return _credsDir || nemoclawStateRoot(home, GATEWAY_PORT); } /** diff --git a/src/lib/domain/uninstall/paths.ts b/src/lib/domain/uninstall/paths.ts index aa2819794c4..4155910dada 100644 --- a/src/lib/domain/uninstall/paths.ts +++ b/src/lib/domain/uninstall/paths.ts @@ -3,6 +3,7 @@ import path from "node:path"; +import { GATEWAY_PORT } from "../../core/ports"; import { nemoclawStateRoot } from "../../state/state-root"; export const DEFAULT_GATEWAY_NAME = "nemoclaw"; @@ -70,7 +71,7 @@ export function defaultUninstallPaths(options: UninstallPathOptions): UninstallP binName, path: path.join(options.home, ".local", "bin", binName), })), - nemoclawStateDir: nemoclawStateRoot(options.home), + nemoclawStateDir: nemoclawStateRoot(options.home, GATEWAY_PORT), gatewayLocalStateDir: path.join(options.home, ".local", "state", "nemoclaw"), openshellConfigDir: path.join(options.home, ".config", "openshell"), openshellInstallPaths: openshellInstallPathsForBinDirs(["/usr/local/bin", xdgBinHome]), diff --git a/src/lib/inference/local-adapter-lifecycle.ts b/src/lib/inference/local-adapter-lifecycle.ts index e012e24c74f..402f14f5530 100644 --- a/src/lib/inference/local-adapter-lifecycle.ts +++ b/src/lib/inference/local-adapter-lifecycle.ts @@ -8,6 +8,7 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { GATEWAY_PORT } from "../core/ports"; import { waitUntilAsync } from "../core/wait"; import { nemoclawStateRoot } from "../state/state-root"; @@ -23,7 +24,7 @@ export type RunFn = ( options?: { ignoreError?: boolean; suppressOutput?: boolean }, ) => unknown; -export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = nemoclawStateRoot(os.homedir()); +export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = nemoclawStateRoot(os.homedir(), GATEWAY_PORT); export function ensureLocalAdapterStateDir(stateDir = DEFAULT_LOCAL_ADAPTER_STATE_DIR): void { if (!fs.existsSync(stateDir)) { diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index cd965d4f1d9..5a07fc4a9d5 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -14,7 +14,7 @@ import { createBearerAuthConfig } from "../adapters/http/auth-config"; import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args"; import type { CurlProbeOptions, CurlProbeResult } from "../adapters/http/probe"; import { runCurlProbe } from "../adapters/http/probe"; -import { OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports"; +import { GATEWAY_PORT, OLLAMA_PORT, OLLAMA_PROXY_PORT, VLLM_PORT } from "../core/ports"; import { sleepSeconds } from "../core/wait"; import { containerCanReachHostLoopback, isWsl } from "../platform"; import { type CaptureResult, runCapture, runCaptureEx, shellQuote } from "../runner"; @@ -245,7 +245,10 @@ export interface LocalProviderHealthProbeOptions { } function defaultLoadOllamaProxyToken(): string | null { - const tokenPath = nodePath.join(nemoclawStateRoot(os.homedir()), "ollama-proxy-token"); + const tokenPath = nodePath.join( + nemoclawStateRoot(os.homedir(), GATEWAY_PORT), + "ollama-proxy-token", + ); try { if (fs.existsSync(tokenPath)) { const token = fs.readFileSync(tokenPath, "utf-8").trim(); diff --git a/src/lib/onboard/docker-gpu-patch-diagnostics.ts b/src/lib/onboard/docker-gpu-patch-diagnostics.ts index 0919c4c4a1d..be5f4289adf 100644 --- a/src/lib/onboard/docker-gpu-patch-diagnostics.ts +++ b/src/lib/onboard/docker-gpu-patch-diagnostics.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { dockerCapture, dockerLogs } from "../adapters/docker"; +import { GATEWAY_PORT } from "../core/ports"; import { nemoclawStateRoot } from "../state/state-root"; import { createDockerGpuDiagnosticRedactor } from "./docker-gpu-diagnostic-redaction"; import { DOCKER_GPU_PATCH_TIMEOUT_MS } from "./docker-gpu-patch-constants"; @@ -147,7 +148,7 @@ export function collectDockerGpuPatchDiagnostics( const logs = deps.dockerLogs ?? dockerLogs; const now = (deps.now ?? (() => new Date()))(); const dir = path.join( - nemoclawStateRoot(home), + nemoclawStateRoot(home, GATEWAY_PORT), "onboard-failures", `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}-docker-gpu-patch`, ); diff --git a/src/lib/onboard/model-router.ts b/src/lib/onboard/model-router.ts index 6f4d707187c..a097cf51f5b 100644 --- a/src/lib/onboard/model-router.ts +++ b/src/lib/onboard/model-router.ts @@ -5,6 +5,7 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { GATEWAY_PORT } from "../core/ports"; import { requireValue } from "../core/require-value"; import { normalizeCredentialValue, @@ -41,7 +42,10 @@ export { const ROUTER_HEALTH_RETRIES = 15; const ROUTER_HEALTH_INTERVAL_MS = 2000; const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router"); -const MODEL_ROUTER_VENV_DIR = path.join(nemoclawStateRoot(os.homedir()), "model-router-venv"); +const MODEL_ROUTER_VENV_DIR = path.join( + nemoclawStateRoot(os.homedir(), GATEWAY_PORT), + "model-router-venv", +); export const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_INFERENCE_API_KEY"; export type BlueprintRouterConfig = { @@ -225,7 +229,7 @@ export async function startModelRouter( blueprintDir, routerCfg.pool_config_path || "router/pool-config.yaml", ); - const stateDir = path.join(nemoclawStateRoot(deps.homeDir), "state"); + const stateDir = path.join(nemoclawStateRoot(deps.homeDir, GATEWAY_PORT), "state"); const litellmConfigPath = path.join(stateDir, "litellm-proxy.yaml"); deps.mkdirSync(stateDir); diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index ee44b960a51..4323e24196f 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { GATEWAY_PORT } from "../core/ports"; import { nemoclawStateRoot } from "../state/state-root"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; @@ -155,7 +156,7 @@ export function collectSandboxCreateFailureDiagnostics( const homeDir = options.homeDir ?? os.homedir(); const now = options.now ?? new Date(); const dir = path.join( - nemoclawStateRoot(homeDir), + nemoclawStateRoot(homeDir, GATEWAY_PORT), "onboard-failures", `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}`, ); diff --git a/src/lib/onboard/usage-notice.ts b/src/lib/onboard/usage-notice.ts index d3ae22d0570..4b42dc9cd84 100644 --- a/src/lib/onboard/usage-notice.ts +++ b/src/lib/onboard/usage-notice.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import noticeConfig from "../../../bin/lib/usage-notice.json"; +import { GATEWAY_PORT } from "../core/ports"; import { nemoclawStateRoot } from "../state/state-root"; export const NOTICE_ACCEPT_FLAG_NAME = "yes-i-accept-third-party-software"; @@ -53,7 +54,10 @@ function parseJson(text: string): T { } export function getUsageNoticeStateFile(): string { - return path.join(nemoclawStateRoot(process.env.HOME || os.homedir()), "usage-notice.json"); + return path.join( + nemoclawStateRoot(process.env.HOME || os.homedir(), GATEWAY_PORT), + "usage-notice.json", + ); } export function loadUsageNoticeConfig(): NoticeConfig { diff --git a/src/lib/share-command.ts b/src/lib/share-command.ts index 2c5fafa7f59..e3bd50975e7 100644 --- a/src/lib/share-command.ts +++ b/src/lib/share-command.ts @@ -13,6 +13,7 @@ import { spawnSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; +import { GATEWAY_PORT } from "./core/ports"; import type { ShareCommandDeps } from "./share-command-deps"; import { buildShareCommandDeps } from "./share-command-deps"; import { nemoclawStateRoot } from "./state/state-root"; @@ -61,7 +62,11 @@ export function isMountPoint(dir: string): boolean { } export function defaultShareMountDir(sandboxName: string): string { - return path.join(nemoclawStateRoot(process.env.HOME || os.homedir()), "mounts", sandboxName); + return path.join( + nemoclawStateRoot(process.env.HOME || os.homedir(), GATEWAY_PORT), + "mounts", + sandboxName, + ); } /** diff --git a/src/lib/state/config-io.ts b/src/lib/state/config-io.ts index 76ab2db00de..1e5e5384f3d 100644 --- a/src/lib/state/config-io.ts +++ b/src/lib/state/config-io.ts @@ -7,6 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isErrnoException, isPermissionError } from "../core/errno"; +import { GATEWAY_PORT } from "../core/ports"; import { shellQuote } from "../core/shell-quote"; import { nemoclawStateRoot } from "./state-root"; @@ -26,7 +27,7 @@ type SerializableConfig = JsonScalar | JsonValue[] | object; // heal opt out cleanly when a caller routes a sandbox-internal path here. function hostNemoclawDir(): string { const home = process.env.HOME ?? os.homedir(); - return path.resolve(nemoclawStateRoot(home)); + return path.resolve(nemoclawStateRoot(home, GATEWAY_PORT)); } function isHostNemoclawRoot(dirPath: string): boolean { @@ -61,7 +62,7 @@ function cleanupTempFile(filePath: string): void { function buildRemediation(): string { const home = process.env.HOME ?? os.homedir(); - const nemoclawDir = nemoclawStateRoot(home); + const nemoclawDir = nemoclawStateRoot(home, GATEWAY_PORT); const backupDir = `${nemoclawDir}.backup.${String(process.pid)}`; const recoveryHome = path.join( os.tmpdir(), diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 2b88b853671..05e8d93b9dc 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -13,6 +13,7 @@ import path from "node:path"; import { isErrnoException } from "../core/errno"; import { isObjectRecord, type JsonObject, type JsonValue } from "../core/json-types"; +import { GATEWAY_PORT } from "../core/ports"; import { normalizeWebSearchConfig, type WebSearchConfig } from "../inference/web-search"; import type { SandboxMessagingPlan } from "../messaging/manifest"; import { compactSandboxMessagingPlanForPersistence } from "../messaging/persistence"; @@ -45,7 +46,7 @@ import { nemoclawStateRoot } from "./state-root"; export const SESSION_VERSION = 1; export const MACHINE_SNAPSHOT_VERSION = 1; -export const SESSION_DIR = nemoclawStateRoot(process.env.HOME || "/tmp"); +export const SESSION_DIR = nemoclawStateRoot(process.env.HOME || "/tmp", GATEWAY_PORT); export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); diff --git a/src/lib/state/paths.ts b/src/lib/state/paths.ts index 21397151039..ffd5d9d2856 100644 --- a/src/lib/state/paths.ts +++ b/src/lib/state/paths.ts @@ -4,13 +4,14 @@ import os from "node:os"; import path from "node:path"; +import { GATEWAY_PORT } from "../core/ports"; import { nemoclawStateRoot } from "./state-root"; export const ROOT = path.resolve(__dirname, "..", "..", ".."); export const SCRIPTS = path.join(ROOT, "scripts"); export function resolveNemoclawHomeDir(homeDir: string = process.env.HOME ?? os.homedir()): string { - return nemoclawStateRoot(homeDir); + return nemoclawStateRoot(homeDir, GATEWAY_PORT); } export function resolveNemoclawStateDir( diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index fe3a4d19dee..8048e340958 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { isErrnoException } from "../core/errno"; import { isObjectRecord } from "../core/json-types"; +import { GATEWAY_PORT } from "../core/ports"; import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields, @@ -163,7 +164,7 @@ export interface SandboxRegistry { export type SandboxRemovalReceipt = reversibleRemoval.RegistryRemovalReceipt; export const REGISTRY_FILE = path.join( - nemoclawStateRoot(process.env.HOME || "/tmp"), + nemoclawStateRoot(process.env.HOME || "/tmp", GATEWAY_PORT), "sandboxes.json", ); export const LOCK_DIR = `${REGISTRY_FILE}.lock`; diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index e7a252825ee..9206508fc51 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -31,6 +31,7 @@ import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isObjectRecord, type UnknownRecord } from "../core/json-types.js"; +import { GATEWAY_PORT } from "../core/ports.js"; import { BACKUP_FAILURE_ABSENT_AFTER_EXTRACTION, classifyFailedDirsFromTarStderr, @@ -58,7 +59,7 @@ import { nemoclawStateRoot } from "./state-root.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); -const REBUILD_BACKUPS_DIR = path.join(nemoclawStateRoot(HOME_DIR), "rebuild-backups"); +const REBUILD_BACKUPS_DIR = path.join(nemoclawStateRoot(HOME_DIR, GATEWAY_PORT), "rebuild-backups"); const MANIFEST_VERSION = 1; export const OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR = @@ -579,7 +580,7 @@ function computeBlueprintDigest(): string | null { // Look for blueprint.yaml relative to the agent-defs ROOT const candidates = [ path.join( - nemoclawStateRoot(process.env.HOME || "/tmp"), + nemoclawStateRoot(process.env.HOME || "/tmp", GATEWAY_PORT), "blueprints", "0.1.0", "blueprint.yaml", From 5ed20084b52a2c27d947c3d4f2a75d6abd9083e5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:04:05 -0700 Subject: [PATCH 05/28] fix(state): harden gateway-scoped lifecycle Signed-off-by: Prekshi Vyas --- .../run-plan-gateway-segregation.test.ts | 114 +++++++++++++++++- src/lib/actions/uninstall/run-plan.ts | 44 +++++-- .../inference/local-adapter-lifecycle.test.ts | 40 +++++- src/lib/inference/local-adapter-lifecycle.ts | 61 +++++++--- test/repro-2666-silent-list-status.test.ts | 21 +++- 5 files changed, 253 insertions(+), 27 deletions(-) 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 11bf59c1e30..fa40e4a08c9 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { type RunResult, runUninstallPlan } from "./run-plan"; @@ -13,6 +13,11 @@ function ok(stdout = ""): RunResult { return { status: 0, stdout, stderr: "" }; } +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + describe("uninstall gateway-port segregation (#3053)", () => { it("preserves the gateways/ subtree so uninstalling one environment leaves the others", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-gwpreserve-")); @@ -66,7 +71,7 @@ describe("uninstall gateway-port segregation (#3053)", () => { target === "/swapfile" || (target.startsWith(tmpHome) && fs.existsSync(target)), isTty: true, log: (line) => logs.push(line), - rmSync: vi.fn(), + rmSync: fs.rmSync, run: (_command, args) => { runCalls.push(args); return ok(); @@ -80,6 +85,111 @@ describe("uninstall gateway-port segregation (#3053)", () => { "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", ); expect(runCalls.some((args) => args[0] === "swapoff")).toBe(false); + expect(fs.existsSync(path.join(stateDir, "managed_swap"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("removes managed swap when the selected non-default port is the final environment", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-final-port-")); + const port = 9123; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(port)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const stateDir = path.join(tmpHome, ".nemoclaw"); + const selectedEnv = path.join(stateDir, "gateways", String(port)); + fs.mkdirSync(selectedEnv, { recursive: true }); + fs.mkdirSync(path.join(stateDir, "backups")); + fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync(path.join(stateDir, "managed_swap"), "/swapfile"); + const defaultSession = path.join(stateDir, "onboard-session.json"); + fs.writeFileSync(defaultSession, "{}"); + const runCalls: string[][] = []; + + const deps = { + commandExists: (command: string) => command !== "docker" && command !== "pgrep", + env: { + HOME: tmpHome, + NEMOCLAW_GATEWAY_PORT: String(port), + NEMOCLAW_NON_INTERACTIVE: "", + } as NodeJS.ProcessEnv, + existsSync: (target: string) => + target === "/swapfile" || (target.startsWith(tmpHome) && fs.existsSync(target)), + isTty: true, + log: vi.fn(), + rmSync: fs.rmSync, + run: (_command: string, args: string[]) => { + runCalls.push(args); + return ok(); + }, + runDocker: () => ok(""), + }; + const options = { assumeYes: true, deleteModels: false, keepOpenShell: true }; + + const protectedResult = runPortUninstall(options, deps); + expect(protectedResult.exitCode).toBe(0); + expect(runCalls.some((args) => args[0] === "swapoff")).toBe(false); + expect(fs.existsSync(path.join(stateDir, "managed_swap"))).toBe(true); + + fs.rmSync(defaultSession); + fs.mkdirSync(selectedEnv, { recursive: true }); + runCalls.length = 0; + const result = runPortUninstall(options, deps); + + expect(result.exitCode).toBe(0); + expect(runCalls).toContainEqual(["swapoff", "/swapfile"]); + expect(runCalls).toContainEqual(["rm", "-f", "/swapfile"]); + expect(fs.existsSync(path.join(stateDir, "managed_swap"))).toBe(false); + expect(fs.existsSync(selectedEnv)).toBe(false); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps managed swap when a sibling non-default port remains", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-sibling-port-")); + const port = 9123; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(port)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const stateDir = path.join(tmpHome, ".nemoclaw"); + const selectedEnv = path.join(stateDir, "gateways", String(port)); + const siblingEnv = path.join(stateDir, "gateways", "9124"); + fs.mkdirSync(selectedEnv, { recursive: true }); + fs.mkdirSync(siblingEnv, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "managed_swap"), "/swapfile"); + const runCalls: string[][] = []; + + const result = runPortUninstall( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { + HOME: tmpHome, + NEMOCLAW_GATEWAY_PORT: String(port), + NEMOCLAW_NON_INTERACTIVE: "", + } as NodeJS.ProcessEnv, + existsSync: (target) => + target === "/swapfile" || (target.startsWith(tmpHome) && fs.existsSync(target)), + isTty: true, + log: vi.fn(), + rmSync: fs.rmSync, + run: (_command, args) => { + runCalls.push(args); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(runCalls.some((args) => args[0] === "swapoff")).toBe(false); + expect(fs.existsSync(path.join(stateDir, "managed_swap"))).toBe(true); + expect(fs.existsSync(selectedEnv)).toBe(false); + expect(fs.existsSync(siblingEnv)).toBe(true); } finally { fs.rmSync(tmpHome, { recursive: true, force: true }); } diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index f7335762618..ac728488ccf 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -157,6 +157,16 @@ const PRESERVED_USER_DATA_ENTRIES: readonly string[] = [ "sandboxes.json", ]; +// These entries can exist in the shared root without representing a running +// default-port environment. Any other shared-root entry is treated +// conservatively as default-port state when uninstalling a non-default port. +const SHARED_HOST_STATE_ENTRIES = new Set([ + ...PRESERVED_USER_DATA_ENTRIES, + "source", + GATEWAYS_SUBDIR, + "managed_swap", +]); + function removePathExcept( target: string, preserve: readonly string[], @@ -811,19 +821,37 @@ function removeOllamaModels(options: UninstallRunOptions, runtime: UninstallRunt function otherGatewayEnvironmentsRemain(paths: UninstallPaths, runtime: UninstallRuntime): boolean { const sharedRoot = path.dirname(paths.managedSwapMarkerPath); - if (paths.nemoclawStateDir !== sharedRoot) return true; - const gatewaysDir = path.join(sharedRoot, "gateways"); + const selectedRoot = path.resolve(paths.nemoclawStateDir); + const selectedIsDefault = selectedRoot === path.resolve(sharedRoot); + + if (!selectedIsDefault && runtime.existsSync(sharedRoot)) { + try { + if (fs.readdirSync(sharedRoot).some((entry) => !SHARED_HOST_STATE_ENTRIES.has(entry))) { + return true; + } + } catch { + // Do not remove a host-shared resource when we cannot prove that the + // default-port environment is absent. + return true; + } + } + + const gatewaysDir = path.join(sharedRoot, GATEWAYS_SUBDIR); if (!runtime.existsSync(gatewaysDir)) return false; try { - return fs.readdirSync(gatewaysDir).length > 0; + return fs + .readdirSync(gatewaysDir) + .some((entry) => path.resolve(gatewaysDir, entry) !== selectedRoot); } catch { - return false; + // An unreadable sibling registry is still potentially live. + return true; } } function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): void { if (!runtime.existsSync("/swapfile")) { runtime.log("No /swapfile found; skipping swap cleanup."); + removePath(paths.managedSwapMarkerPath, runtime); return; } if (!runtime.existsSync(paths.managedSwapMarkerPath)) { @@ -851,8 +879,10 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo return; } const rm = runtime.run("sudo", ["rm", "-f", "/swapfile"], { env: runtime.env, stdio: "ignore" }); - if (rm.status === 0) runtime.log("Swap file removed"); - else runtime.warn("Failed to remove /swapfile."); + if (rm.status === 0) { + runtime.log("Swap file removed"); + removePath(paths.managedSwapMarkerPath, runtime); + } else runtime.warn("Failed to remove /swapfile."); } function detectPreservableEntries(paths: UninstallPaths, runtime: UninstallRuntime): string[] { @@ -980,7 +1010,7 @@ function executePlan( if ( !removePathExcept( paths.nemoclawStateDir, - [...preserveUnderStateDir, GATEWAYS_SUBDIR], + [...preserveUnderStateDir, GATEWAYS_SUBDIR, path.basename(paths.managedSwapMarkerPath)], runtime, ) ) diff --git a/src/lib/inference/local-adapter-lifecycle.test.ts b/src/lib/inference/local-adapter-lifecycle.test.ts index 527aceb20ab..abe573c2717 100644 --- a/src/lib/inference/local-adapter-lifecycle.test.ts +++ b/src/lib/inference/local-adapter-lifecycle.test.ts @@ -6,7 +6,7 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureLocalAdapterStateDir, @@ -39,6 +39,7 @@ afterEach(async () => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } + vi.unstubAllEnvs(); }); function tempDir(): string { @@ -151,6 +152,43 @@ describe("ensureLocalAdapterStateDir", () => { const stat = fs.statSync(stateDir); expect(stat.mode & 0o777).toBe(0o700); }); + + it.each([ + "gateways", + "selected port", + ])("rejects a symlink at the %s ancestor before writing adapter secrets (#3053)", (symlinkAt) => { + if (process.platform === "win32") return; + const home = tempDir(); + vi.stubEnv("HOME", home); + const controlled = path.join(home, "controlled"); + const sharedRoot = path.join(home, ".nemoclaw"); + const gatewaysDir = path.join(sharedRoot, "gateways"); + const selectedDir = path.join(gatewaysDir, "9123"); + fs.mkdirSync(controlled, { recursive: true }); + fs.mkdirSync(symlinkAt === "gateways" ? sharedRoot : gatewaysDir, { recursive: true }); + fs.symlinkSync(controlled, symlinkAt === "gateways" ? gatewaysDir : selectedDir); + + expect(() => + writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), + ).toThrow(/symbolic link/); + expect(fs.existsSync(path.join(controlled, "adapter-token"))).toBe(false); + }); + + it("refuses to overwrite an adapter secret through a final-component symlink", () => { + if (process.platform === "win32") return; + const home = tempDir(); + vi.stubEnv("HOME", home); + const selectedDir = path.join(home, ".nemoclaw", "gateways", "9123"); + const controlled = path.join(home, "controlled-token"); + fs.mkdirSync(selectedDir, { recursive: true }); + fs.writeFileSync(controlled, "unchanged\n", { mode: 0o600 }); + fs.symlinkSync(controlled, path.join(selectedDir, "adapter-token")); + + expect(() => + writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), + ).toThrow(); + expect(fs.readFileSync(controlled, "utf8")).toBe("unchanged\n"); + }); }); describe("waitForLocalAdapterHealth", () => { diff --git a/src/lib/inference/local-adapter-lifecycle.ts b/src/lib/inference/local-adapter-lifecycle.ts index 402f14f5530..66a9aef11e3 100644 --- a/src/lib/inference/local-adapter-lifecycle.ts +++ b/src/lib/inference/local-adapter-lifecycle.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { GATEWAY_PORT } from "../core/ports"; import { waitUntilAsync } from "../core/wait"; +import { rejectSymlinksOnPath } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; export type JsonObject = Record; @@ -27,17 +28,18 @@ export type RunFn = ( export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = nemoclawStateRoot(os.homedir(), GATEWAY_PORT); export function ensureLocalAdapterStateDir(stateDir = DEFAULT_LOCAL_ADAPTER_STATE_DIR): void { + rejectSymlinksOnPath(stateDir); if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } + rejectSymlinksOnPath(stateDir); // Tighten permissions in case the directory was created with a lax umask. - try { - const stat = fs.statSync(stateDir); - if ((stat.mode & 0o077) !== 0) { - fs.chmodSync(stateDir, 0o700); - } - } catch { - // Best effort — stat/chmod may fail on non-POSIX or read-only fs. + const stat = fs.lstatSync(stateDir); + if (!stat.isDirectory()) { + throw new Error(`Refusing to use local adapter state path: ${stateDir} is not a directory`); + } + if ((stat.mode & 0o077) !== 0) { + fs.chmodSync(stateDir, 0o700); } } @@ -45,10 +47,41 @@ function ensureParentDir(filePath: string): void { ensureLocalAdapterStateDir(path.dirname(filePath)); } -export function writeLocalAdapterSecretFile(filePath: string, value: string): void { +function writePrivateLocalAdapterFile(filePath: string, value: string, append = false): void { ensureParentDir(filePath); - fs.writeFileSync(filePath, `${value}\n`, { mode: 0o600 }); - fs.chmodSync(filePath, 0o600); + + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + if (noFollow === 0) { + try { + if (fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error(`Refusing to write local adapter state through symbolic link: ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + (append ? fs.constants.O_APPEND : fs.constants.O_TRUNC) | + noFollow | + (fs.constants.O_NONBLOCK ?? 0); + let fd: number | undefined; + try { + fd = fs.openSync(filePath, flags, 0o600); + if (!fs.fstatSync(fd).isFile()) { + throw new Error(`Refusing to write local adapter state to non-file path: ${filePath}`); + } + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, value, "utf8"); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +export function writeLocalAdapterSecretFile(filePath: string, value: string): void { + writePrivateLocalAdapterFile(filePath, `${value}\n`); } export function readLocalAdapterTextFile(filePath: string): string | null { @@ -61,15 +94,11 @@ export function readLocalAdapterTextFile(filePath: string): string | null { } export function writeLocalAdapterJsonFile(filePath: string, value: unknown): void { - ensureParentDir(filePath); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); - fs.chmodSync(filePath, 0o600); + writePrivateLocalAdapterFile(filePath, `${JSON.stringify(value, null, 2)}\n`); } export function appendLocalAdapterJsonLine(filePath: string, value: unknown): void { - ensureParentDir(filePath); - fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); - fs.chmodSync(filePath, 0o600); + writePrivateLocalAdapterFile(filePath, `${JSON.stringify(value)}\n`, true); } export function readLocalAdapterJsonFile(filePath: string): JsonObject | null { diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index 22e206aeef5..dc2b0ef2de6 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -214,7 +214,10 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress fs.rmSync(home, { recursive: true, force: true }); }); - function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + function runCli( + args: string[], + envOverrides: NodeJS.ProcessEnv = {}, + ): { code: number; stdout: string; stderr: string } { const result = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf-8", timeout: 30_000, @@ -226,6 +229,8 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress NEMOCLAW_HEALTH_POLL_INTERVAL: "0", NEMOCLAW_STATUS_PROBE_TIMEOUT_MS: "2000", NEMOCLAW_TEST_NO_SLEEP: "1", + NEMOCLAW_GATEWAY_PORT: "", + ...envOverrides, }, }); return { @@ -284,6 +289,20 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress expect(code).toBe(0); }); + it("nemoclaw list reads the registry scoped to a non-default gateway port (#3053)", () => { + const port = 9123; + fs.rmSync(path.join(home, ".nemoclaw", "sandboxes.json"), { force: true }); + seedRegistry(nemoclawStateRoot(home, port)); + + const { code, stdout, stderr } = runCli(["list"], { + NEMOCLAW_GATEWAY_PORT: String(port), + }); + const combined = `${stdout}\n${stderr}`; + + expect(code).toBe(0); + expect(combined).toContain("my-assist"); + }); + it( "nemoclaw status never produces silent empty output when openshell is broken", testTimeoutOptions(30_000), From 618294b0b4aeb4b94324277c182bdcaad00d064c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:07:17 -0700 Subject: [PATCH 06/28] test(state): use declarative symlink platform gate Signed-off-by: Prekshi Vyas --- .../inference/local-adapter-lifecycle.test.ts | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/lib/inference/local-adapter-lifecycle.test.ts b/src/lib/inference/local-adapter-lifecycle.test.ts index abe573c2717..22b884a7f45 100644 --- a/src/lib/inference/local-adapter-lifecycle.test.ts +++ b/src/lib/inference/local-adapter-lifecycle.test.ts @@ -153,41 +153,41 @@ describe("ensureLocalAdapterStateDir", () => { expect(stat.mode & 0o777).toBe(0o700); }); - it.each([ - "gateways", - "selected port", - ])("rejects a symlink at the %s ancestor before writing adapter secrets (#3053)", (symlinkAt) => { - if (process.platform === "win32") return; - const home = tempDir(); - vi.stubEnv("HOME", home); - const controlled = path.join(home, "controlled"); - const sharedRoot = path.join(home, ".nemoclaw"); - const gatewaysDir = path.join(sharedRoot, "gateways"); - const selectedDir = path.join(gatewaysDir, "9123"); - fs.mkdirSync(controlled, { recursive: true }); - fs.mkdirSync(symlinkAt === "gateways" ? sharedRoot : gatewaysDir, { recursive: true }); - fs.symlinkSync(controlled, symlinkAt === "gateways" ? gatewaysDir : selectedDir); - - expect(() => - writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), - ).toThrow(/symbolic link/); - expect(fs.existsSync(path.join(controlled, "adapter-token"))).toBe(false); - }); + describe.skipIf(process.platform === "win32")("symlink-safe adapter state", () => { + it.each([ + "gateways", + "selected port", + ])("rejects a symlink at the %s ancestor before writing adapter secrets (#3053)", (symlinkAt) => { + const home = tempDir(); + vi.stubEnv("HOME", home); + const controlled = path.join(home, "controlled"); + const sharedRoot = path.join(home, ".nemoclaw"); + const gatewaysDir = path.join(sharedRoot, "gateways"); + const selectedDir = path.join(gatewaysDir, "9123"); + fs.mkdirSync(controlled, { recursive: true }); + fs.mkdirSync(symlinkAt === "gateways" ? sharedRoot : gatewaysDir, { recursive: true }); + fs.symlinkSync(controlled, symlinkAt === "gateways" ? gatewaysDir : selectedDir); + + expect(() => + writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), + ).toThrow(/symbolic link/); + expect(fs.existsSync(path.join(controlled, "adapter-token"))).toBe(false); + }); - it("refuses to overwrite an adapter secret through a final-component symlink", () => { - if (process.platform === "win32") return; - const home = tempDir(); - vi.stubEnv("HOME", home); - const selectedDir = path.join(home, ".nemoclaw", "gateways", "9123"); - const controlled = path.join(home, "controlled-token"); - fs.mkdirSync(selectedDir, { recursive: true }); - fs.writeFileSync(controlled, "unchanged\n", { mode: 0o600 }); - fs.symlinkSync(controlled, path.join(selectedDir, "adapter-token")); - - expect(() => - writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), - ).toThrow(); - expect(fs.readFileSync(controlled, "utf8")).toBe("unchanged\n"); + it("refuses to overwrite an adapter secret through a final-component symlink", () => { + const home = tempDir(); + vi.stubEnv("HOME", home); + const selectedDir = path.join(home, ".nemoclaw", "gateways", "9123"); + const controlled = path.join(home, "controlled-token"); + fs.mkdirSync(selectedDir, { recursive: true }); + fs.writeFileSync(controlled, "unchanged\n", { mode: 0o600 }); + fs.symlinkSync(controlled, path.join(selectedDir, "adapter-token")); + + expect(() => + writeLocalAdapterSecretFile(path.join(selectedDir, "adapter-token"), "secret"), + ).toThrow(); + expect(fs.readFileSync(controlled, "utf8")).toBe("unchanged\n"); + }); }); }); From 9bac22dbba7ac777434b515ec4837dea8ec9d714 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:15:49 -0700 Subject: [PATCH 07/28] fix(state): secure remaining port-scoped writers Signed-off-by: Prekshi Vyas --- .../onboard/docker-gpu-patch-diagnostics.ts | 3 + .../onboard/port-state-writer-symlink.test.ts | 125 ++++++++++++++++++ src/lib/onboard/sandbox-create-failure.ts | 3 + src/lib/onboard/usage-notice.ts | 14 +- test/credentials.test.ts | 29 +++- 5 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/lib/onboard/port-state-writer-symlink.test.ts diff --git a/src/lib/onboard/docker-gpu-patch-diagnostics.ts b/src/lib/onboard/docker-gpu-patch-diagnostics.ts index be5f4289adf..5cd0f4b4671 100644 --- a/src/lib/onboard/docker-gpu-patch-diagnostics.ts +++ b/src/lib/onboard/docker-gpu-patch-diagnostics.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { dockerCapture, dockerLogs } from "../adapters/docker"; import { GATEWAY_PORT } from "../core/ports"; +import { rejectSymlinksOnPath } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; import { createDockerGpuDiagnosticRedactor } from "./docker-gpu-diagnostic-redaction"; import { DOCKER_GPU_PATCH_TIMEOUT_MS } from "./docker-gpu-patch-constants"; @@ -153,7 +154,9 @@ export function collectDockerGpuPatchDiagnostics( `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}-docker-gpu-patch`, ); try { + rejectSymlinksOnPath(dir); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + rejectSymlinksOnPath(dir); } catch { return null; } diff --git a/src/lib/onboard/port-state-writer-symlink.test.ts b/src/lib/onboard/port-state-writer-symlink.test.ts new file mode 100644 index 00000000000..01cc1f44e67 --- /dev/null +++ b/src/lib/onboard/port-state-writer-symlink.test.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const GATEWAY_PORT = 9123; + +type PortStateWriters = { + saveUsageNoticeAcceptance: (version: string) => void; + collectSandboxCreateFailureDiagnostics: ( + sandboxName: string, + options: { homeDir: string; now: Date }, + ) => { dir: string } | null; + collectDockerGpuPatchDiagnostics: ( + sandboxName: string, + options: object, + deps: { + dockerCapture: () => string; + dockerLogs: () => string; + homedir: () => string; + now: () => Date; + }, + ) => { dir: string } | null; +}; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function loadPortStateWriters(home: string): Promise { + vi.resetModules(); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(GATEWAY_PORT)); + + const usageNotice = await import("./usage-notice.js"); + const sandboxDiagnostics = await import("./sandbox-create-failure.js"); + const dockerDiagnostics = await import("./docker-gpu-patch-diagnostics.js"); + return { + saveUsageNoticeAcceptance: usageNotice.saveUsageNoticeAcceptance, + collectSandboxCreateFailureDiagnostics: + sandboxDiagnostics.collectSandboxCreateFailureDiagnostics, + collectDockerGpuPatchDiagnostics: dockerDiagnostics.collectDockerGpuPatchDiagnostics, + }; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(process.platform === "win32")("port-scoped host-state writers", () => { + it("writes each artifact beneath the selected nondefault gateway root", async () => { + const home = makeTempDir("nemoclaw-port-writers-home-"); + const writers = await loadPortStateWriters(home); + const now = new Date("2026-07-13T12:00:00.000Z"); + + writers.saveUsageNoticeAcceptance("test-version"); + const sandboxDiagnostics = writers.collectSandboxCreateFailureDiagnostics("sandbox", { + homeDir: home, + now, + }); + const dockerDiagnostics = writers.collectDockerGpuPatchDiagnostics( + "sandbox", + {}, + { + dockerCapture: () => "", + dockerLogs: () => "", + homedir: () => home, + now: () => now, + }, + ); + + const selectedRoot = path.join(home, ".nemoclaw", "gateways", String(GATEWAY_PORT)); + expect(fs.existsSync(path.join(selectedRoot, "usage-notice.json"))).toBe(true); + expect(sandboxDiagnostics?.dir.startsWith(selectedRoot)).toBe(true); + expect(dockerDiagnostics?.dir.startsWith(selectedRoot)).toBe(true); + }); + + it.each([ + "gateways", + "port", + ] as const)("rejects a symlinked %s state ancestor without writing through it", async (symlinkLevel) => { + const home = makeTempDir("nemoclaw-port-writers-home-"); + const controlled = makeTempDir("nemoclaw-port-writers-target-"); + const sharedRoot = path.join(home, ".nemoclaw"); + const gatewaysRoot = path.join(sharedRoot, "gateways"); + fs.mkdirSync(symlinkLevel === "gateways" ? sharedRoot : gatewaysRoot, { recursive: true }); + fs.symlinkSync( + controlled, + symlinkLevel === "gateways" ? gatewaysRoot : path.join(gatewaysRoot, String(GATEWAY_PORT)), + "dir", + ); + const writers = await loadPortStateWriters(home); + const now = new Date("2026-07-13T12:00:00.000Z"); + + expect(() => writers.saveUsageNoticeAcceptance("test-version")).toThrow(/symbolic link/i); + expect( + writers.collectSandboxCreateFailureDiagnostics("sandbox", { homeDir: home, now }), + ).toBeNull(); + expect( + writers.collectDockerGpuPatchDiagnostics( + "sandbox", + {}, + { + dockerCapture: () => "", + dockerLogs: () => "", + homedir: () => home, + now: () => now, + }, + ), + ).toBeNull(); + expect(fs.readdirSync(controlled)).toEqual([]); + }); +}); diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index 4323e24196f..87ed446fe49 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { GATEWAY_PORT } from "../core/ports"; +import { rejectSymlinksOnPath } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; @@ -162,7 +163,9 @@ export function collectSandboxCreateFailureDiagnostics( ); try { + rejectSymlinksOnPath(dir); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + rejectSymlinksOnPath(dir); } catch { return null; } diff --git a/src/lib/onboard/usage-notice.ts b/src/lib/onboard/usage-notice.ts index 4b42dc9cd84..c44a3e665e5 100644 --- a/src/lib/onboard/usage-notice.ts +++ b/src/lib/onboard/usage-notice.ts @@ -7,6 +7,7 @@ import path from "node:path"; import noticeConfig from "../../../bin/lib/usage-notice.json"; import { GATEWAY_PORT } from "../core/ports"; +import { writeConfigFile } from "../state/config-io"; import { nemoclawStateRoot } from "../state/state-root"; export const NOTICE_ACCEPT_FLAG_NAME = "yes-i-accept-third-party-software"; @@ -85,15 +86,10 @@ export function hasAcceptedUsageNotice(version: string): boolean { export function saveUsageNoticeAcceptance(version: string): void { const stateFile = getUsageNoticeStateFile(); - const dir = path.dirname(stateFile); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.chmodSync(dir, 0o700); - fs.writeFileSync( - stateFile, - JSON.stringify({ acceptedVersion: version, acceptedAt: new Date().toISOString() }, null, 2), - { mode: 0o600 }, - ); - fs.chmodSync(stateFile, 0o600); + writeConfigFile(stateFile, { + acceptedVersion: version, + acceptedAt: new Date().toISOString(), + }); } export function supportsTerminalHyperlinks(): boolean { diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 4841f7be45e..520c570e805 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -39,12 +39,16 @@ function clearTrackedEnv() { } } -async function importCredentialsModule(home: string): Promise { +async function importCredentialsModule( + home: string, + gatewayPort?: number, +): Promise { vi.resetModules(); vi.doUnmock("fs"); vi.doUnmock("child_process"); vi.doUnmock("readline"); vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", gatewayPort === undefined ? "" : String(gatewayPort)); const module = await import("../src/lib/credentials/store.js"); const loaded = "default" in module ? module.default : module; const moduleObject = typeof loaded === "object" && loaded !== null ? loaded : null; @@ -179,6 +183,29 @@ describe("host-side credential staging", () => { }); describe("legacy credentials.json migration (two-phase: stage then remove)", () => { + it("stages credentials only from the selected nondefault gateway root", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-port-")); + const defaultDir = path.join(home, ".nemoclaw"); + const selectedDir = path.join(defaultDir, "gateways", "9123"); + fs.mkdirSync(selectedDir, { recursive: true }); + fs.writeFileSync( + path.join(defaultDir, "credentials.json"), + JSON.stringify({ NVIDIA_INFERENCE_API_KEY: "nvapi-default-root" }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(selectedDir, "credentials.json"), + JSON.stringify({ NVIDIA_INFERENCE_API_KEY: "nvapi-selected-port" }), + { mode: 0o600 }, + ); + + const credentials = await importCredentialsModule(home, 9123); + + expect(credentials.stageLegacyCredentialsToEnv()).toEqual(["NVIDIA_INFERENCE_API_KEY"]); + expect(process.env.NVIDIA_INFERENCE_API_KEY).toBe("nvapi-selected-port"); + expect(fs.existsSync(path.join(defaultDir, "credentials.json"))).toBe(true); + }); + it("stages allowlisted keys into env without touching the file", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); const credsDir = path.join(home, ".nemoclaw"); From a2fa5bae0a86251b7ab55595047d2cc93a8b723c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:29:06 -0700 Subject: [PATCH 08/28] test(state): close port-scoped consumer gaps Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/lifecycle.mdx | 4 +- docs/reference/architecture.mdx | 2 +- src/lib/inference/local.test.ts | 39 +++++++++++++++ src/lib/inference/local.ts | 4 +- src/lib/share-command.test.ts | 18 ++++++- src/lib/share-command.ts | 3 ++ test/onboard-model-router.test.ts | 56 ++++++++++++++++++++++ test/repro-2666-silent-list-status.test.ts | 24 +++++++++- test/share-command-writable.test.ts | 33 +++++++++++++ 9 files changed, 175 insertions(+), 8 deletions(-) diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 29c2c3117eb..52f1d06e421 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -147,14 +147,14 @@ When the default API port is already held by another sandbox, `$$nemoclaw onboar If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. Gateway and dashboard cleanup is scoped by sandbox name and port. A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward. If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, credentials, and snapshots, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. The default port keeps the shared `~/.nemoclaw/` location, and `nemo-deepagents uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location, and `nemo-deepagents uninstall` on one port leaves the other ports' state in place. Gateway cleanup is scoped by sandbox name and port. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index d18fd0c852d..e7c4f2f6b99 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -308,7 +308,7 @@ NemoClaw keeps non-secret operator-facing state on the host rather than inside t | Path | Purpose | |---|---| | `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata for the default gateway port, including the default sandbox selection. | -| `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, credentials, and snapshots) for a non-default `NEMOCLAW_GATEWAY_PORT`. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | +| `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, snapshots, and legacy credential-migration files) for a non-default `NEMOCLAW_GATEWAY_PORT`. Provider credentials remain in the OpenShell gateway store. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | | `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | diff --git a/src/lib/inference/local.test.ts b/src/lib/inference/local.test.ts index 74d472c70fb..a8370d10fbf 100644 --- a/src/lib/inference/local.test.ts +++ b/src/lib/inference/local.test.ts @@ -383,6 +383,45 @@ describe("local inference helpers", () => { }); }); + it("loads the Ollama proxy token only from the selected nondefault gateway root", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ollama-port-token-")); + const defaultRoot = path.join(home, ".nemoclaw"); + const selectedRoot = path.join(defaultRoot, "gateways", "9123"); + fs.mkdirSync(selectedRoot, { recursive: true }); + fs.writeFileSync(path.join(defaultRoot, "ollama-proxy-token"), "default-root-token\n"); + fs.writeFileSync(path.join(selectedRoot, "ollama-proxy-token"), "selected-port-token\n"); + vi.stubEnv("HOME", home); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "9123"); + vi.resetModules(); + + try { + const freshLocal = await import("./local"); + let authConfig = ""; + const result = freshLocal.probeOllamaAuthProxyHealth({ + runCurlProbeImpl: (_argv, options) => { + const configPath = options?.trustedConfigFiles?.[0] ?? ""; + authConfig = fs.readFileSync(configPath, "utf8"); + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"models":[]}', + stderr: "", + message: "HTTP 200", + }; + }, + }); + + expect(result?.ok).toBe(true); + expect(authConfig).toContain("selected-port-token"); + expect(authConfig).not.toContain("default-root-token"); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("surfaces 401 on the auth-proxy subprobe even when backend is healthy", () => { const result = probeLocalProviderHealth("ollama-local", { loadOllamaProxyTokenImpl: () => "stale-token", diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index 5a07fc4a9d5..b3a74918c7c 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -238,8 +238,8 @@ export interface LocalProviderHealthProbeOptions { skipOllamaAuthProxySubprobe?: boolean; /** * Reads the persisted Ollama auth-proxy bearer token. Injectable for tests. - * Default reads from `~/.nemoclaw/ollama-proxy-token` (written by - * inference/ollama/proxy.ts during onboard). + * Default reads from `ollama-proxy-token` in the selected gateway's host + * state root (written by inference/ollama/proxy.ts during onboard). */ loadOllamaProxyTokenImpl?: () => string | null; } diff --git a/src/lib/share-command.test.ts b/src/lib/share-command.test.ts index 2f2a3b866af..49416593552 100644 --- a/src/lib/share-command.test.ts +++ b/src/lib/share-command.test.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnSyncMock } = vi.hoisted(() => ({ spawnSyncMock: vi.fn(), @@ -59,6 +59,8 @@ describe("share-command helpers", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); }); it("builds the default mount directory under ~/.nemoclaw/mounts", () => { @@ -75,6 +77,20 @@ describe("share-command helpers", () => { } }); + it("builds the default mount directory under the selected nondefault gateway root", async () => { + vi.stubEnv("HOME", "/home/tester"); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "9123"); + vi.resetModules(); + const freshShareCommand = await import("./share-command"); + + expect(freshShareCommand.defaultShareMountDir("alpha")).toBe( + "/home/tester/.nemoclaw/gateways/9123/mounts/alpha", + ); + expect(freshShareCommand.defaultShareMountDir("alpha")).not.toBe( + "/home/tester/.nemoclaw/mounts/alpha", + ); + }); + it("falls back to mount output when mountpoint is unavailable", () => { withProcessPlatform("linux", () => { const dir = "/tmp/nemoclaw-share-mounted"; diff --git a/src/lib/share-command.ts b/src/lib/share-command.ts index e3bd50975e7..0fd317bb9b3 100644 --- a/src/lib/share-command.ts +++ b/src/lib/share-command.ts @@ -16,6 +16,7 @@ import path from "path"; import { GATEWAY_PORT } from "./core/ports"; import type { ShareCommandDeps } from "./share-command-deps"; import { buildShareCommandDeps } from "./share-command-deps"; +import { rejectSymlinksOnPath } from "./state/config-io"; import { nemoclawStateRoot } from "./state/state-root"; export class ShareCommandError extends Error { @@ -129,6 +130,7 @@ export function checkLocalMountWritable(localMount: string): { reason?: string; } { try { + rejectSymlinksOnPath(localMount); // Node's fs.mkdirSync(path, { recursive: true }) masks EROFS as ENOENT when // the leaf is missing on a read-only parent (#4311). Use non-recursive mkdir // when the parent already exists so EROFS propagates with its true errno; @@ -145,6 +147,7 @@ export function checkLocalMountWritable(localMount: string): { } else { fs.mkdirSync(localMount, { recursive: true }); } + rejectSymlinksOnPath(localMount); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException | undefined)?.code; if (code === "EROFS") return { writable: false, reason: "parent filesystem is read-only" }; diff --git a/test/onboard-model-router.test.ts b/test/onboard-model-router.test.ts index d6fccf00bed..f4ecc524e1f 100644 --- a/test/onboard-model-router.test.ts +++ b/test/onboard-model-router.test.ts @@ -60,6 +60,7 @@ const tempDirs = new Set(); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + vi.resetModules(); for (const tmpDir of tempDirs) { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -378,6 +379,61 @@ describe("onboard Model Router setup", () => { ); }); + it("writes router state beneath the selected nondefault gateway root", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-port-")); + tempDirs.add(tmpDir); + const rootDir = path.join(tmpDir, "repo"); + const homeDir = path.join(tmpDir, "home"); + const expectedStateDir = path.join(homeDir, ".nemoclaw", "gateways", "9123", "state"); + const expectedConfig = path.join(expectedStateDir, "litellm-proxy.yaml"); + const mkdirSync = vi.fn(); + const proxyConfigArgs: string[][] = []; + const proxyArgs: string[][] = []; + let healthProbe = 0; + vi.stubEnv("HOME", homeDir); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "9123"); + vi.resetModules(); + const freshModelRouter = await import("../src/lib/onboard/model-router"); + + const pid = await freshModelRouter.startModelRouter( + { port: 45_679, pool_config_path: "router/test-pool.yaml" }, + { + rootDir, + homeDir, + ensureModelRouterCommand: () => "/test/model-router", + mkdirSync, + runProxyConfig: (_command, args) => { + proxyConfigArgs.push(args); + return { status: 0 }; + }, + spawnProxy: (_command, args) => { + proxyArgs.push(args); + return { + pid: 12_345, + onError: () => undefined, + onExit: () => undefined, + unref: () => undefined, + }; + }, + resolveProviderCredential: () => null, + buildSubprocessEnv: () => ({}), + isRouterHealthy: async () => { + healthProbe += 1; + return healthProbe > 1; + }, + sleep: async () => undefined, + isProcessAlive: () => true, + terminateProcess: () => undefined, + getProviderKey: () => "", + }, + ); + + assert.equal(pid, 12_345); + assert.deepEqual(mkdirSync.mock.calls, [[expectedStateDir]]); + assert.equal(proxyConfigArgs[0]?.at(-1), expectedConfig); + assert.equal(proxyArgs[0]?.[2], expectedConfig); + }); + it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", () => { const pathCommand = "/tmp/path-model-router"; const harness = createCommandHarness({ pathCommand }); diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index dc2b0ef2de6..fff5f79ff2b 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -248,7 +248,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress fs.writeFileSync(path.join(binDir, "openshell"), lines.join("\n"), { mode: 0o755 }); } - function seedRegistry(stateDir: string): void { + function seedRegistry(stateDir: string, model = "test-model"): void { fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync( path.join(stateDir, "sandboxes.json"), @@ -256,7 +256,7 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress sandboxes: { "my-assist": { name: "my-assist", - model: "test-model", + model, provider: "nvidia-prod", gpuEnabled: false, policies: [], @@ -303,6 +303,26 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress expect(combined).toContain("my-assist"); }); + it( + "nemoclaw status reads only the registry scoped to a non-default gateway port (#3053)", + testTimeoutOptions(30_000), + () => { + const port = 9123; + seedRegistry(path.join(home, ".nemoclaw"), "default-root-model"); + seedRegistry(nemoclawStateRoot(home, port), "selected-port-model"); + + const { code, stdout, stderr } = runCli(["my-assist", "status"], { + NEMOCLAW_GATEWAY_PORT: String(port), + }); + const combined = `${stdout}\n${stderr}`; + + expect(code).not.toBe(0); + expect(combined).toContain("my-assist"); + expect(combined).toContain("selected-port-model"); + expect(combined).not.toContain("default-root-model"); + }, + ); + it( "nemoclaw status never produces silent empty output when openshell is broken", testTimeoutOptions(30_000), diff --git a/test/share-command-writable.test.ts b/test/share-command-writable.test.ts index 0c1f42a1145..171e6f0e1d6 100644 --- a/test/share-command-writable.test.ts +++ b/test/share-command-writable.test.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "fs"; +import os from "os"; +import path from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { checkLocalMountWritable } from "../src/lib/share-command.js"; @@ -9,6 +11,7 @@ import { checkLocalMountWritable } from "../src/lib/share-command.js"; describe("checkLocalMountWritable (#3192)", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it("returns writable=true when mkdirSync and accessSync both succeed", () => { @@ -169,3 +172,33 @@ describe("checkLocalMountWritable (#3192)", () => { }); }); }); + +describe.skipIf(process.platform === "win32")("checkLocalMountWritable symlink safety", () => { + it.each([ + ["gateways", 1], + ["selected port", 2], + ["mounts", 3], + ["mount target", 4], + ] as const)("rejects a symlinked %s path component", (_label, symlinkIndex) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-share-home-")); + const controlled = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-share-target-")); + const components = [".nemoclaw", "gateways", "9123", "mounts", "alpha"]; + const localMount = path.join(home, ...components); + const symlinkPath = path.join(home, ...components.slice(0, symlinkIndex + 1)); + vi.stubEnv("HOME", home); + + try { + fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); + fs.symlinkSync(controlled, symlinkPath, "dir"); + + expect(checkLocalMountWritable(localMount)).toMatchObject({ + writable: false, + reason: expect.stringMatching(/symbolic link/i), + }); + expect(fs.readdirSync(controlled)).toEqual([]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(controlled, { recursive: true, force: true }); + } + }); +}); From 5f0a409254c8dde110980b51d11ffbd126965c9a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:42:44 -0700 Subject: [PATCH 09/28] fix(state): report selected backup root --- src/lib/actions/maintenance.test.ts | 13 ++++++++++++- src/lib/actions/maintenance.ts | 10 +++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index e8684bc7438..2e53095b0dd 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -43,7 +43,12 @@ vi.mock("../domain/lifecycle/options", () => ({ // ../domain/maintenance/images is left unmocked so the gc tests run the real // orphan-detection helpers and can assert on gc's actual output. -import { backupAll, garbageCollectImages, shouldSkipUnreachableSandboxBackup } from "./maintenance"; +import { + backupAll, + garbageCollectImages, + rebuildBackupsDirectory, + shouldSkipUnreachableSandboxBackup, +} from "./maintenance"; describe("backupAll", () => { beforeEach(() => { @@ -62,6 +67,12 @@ describe("backupAll", () => { vi.restoreAllMocks(); }); + it("reports the rebuild backup directory under the selected gateway state root", () => { + expect(rebuildBackupsDirectory("/home/tester", 9123)).toBe( + "/home/tester/.nemoclaw/gateways/9123/rebuild-backups", + ); + }); + it("returns before gateway preflight when no sandboxes are registered", async () => { mocks.listSandboxes.mockReturnValue({ sandboxes: [], defaultSandbox: null }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/src/lib/actions/maintenance.ts b/src/lib/actions/maintenance.ts index 77a7ee1f203..5ffe7875b1c 100644 --- a/src/lib/actions/maintenance.ts +++ b/src/lib/actions/maintenance.ts @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import { dockerListImagesFormat, dockerRmi } from "../adapters/docker"; import { CLI_NAME } from "../cli/branding"; +import { GATEWAY_PORT } from "../core/ports"; import { prompt as askPrompt } from "../credentials/store"; import { formatFailedBackupItems } from "../domain/backup-failure"; import { @@ -15,6 +18,7 @@ import { captureSandboxListWithGatewayPreflightOrExit } from "../openshell-sandb import { parseReadySandboxNames } from "../runtime-recovery"; import * as registry from "../state/registry"; import * as sandboxState from "../state/sandbox"; +import { nemoclawStateRoot, resolveHome } from "../state/state-root"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = @@ -29,6 +33,10 @@ export function shouldSkipUnreachableSandboxBackup(env: NodeJS.ProcessEnv): bool return env.NEMOCLAW_SKIP_UNREACHABLE_SANDBOX_BACKUP === "1"; } +export function rebuildBackupsDirectory(home: string, gatewayPort: number): string { + return path.join(nemoclawStateRoot(home, gatewayPort), "rebuild-backups"); +} + function notRunningBackupSkipMessage(name: string): string { return `Skipping '${name}' (not running; start the sandbox/container and rerun '${CLI_NAME} backup-all' so NemoClaw can capture a fresh snapshot)`; } @@ -131,7 +139,7 @@ export async function backupAll(): Promise { console.log(""); console.log(` Pre-upgrade backup: ${backed} backed up, ${failed} failed, ${skipped} skipped`); if (backed > 0) { - console.log(` Backups stored in: ~/.nemoclaw/rebuild-backups/`); + console.log(` Backups stored in: ${rebuildBackupsDirectory(resolveHome(), GATEWAY_PORT)}`); } if (failed > 0) { if (unreachableRunning > 0) { From 479c03322056cbe64bc756282a6c8354464d5927 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 09:53:33 -0700 Subject: [PATCH 10/28] fix(state): reject symlinked model router roots --- src/lib/onboard/model-router.ts | 3 ++ test/onboard-model-router.test.ts | 70 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/lib/onboard/model-router.ts b/src/lib/onboard/model-router.ts index a097cf51f5b..11a32de3ab9 100644 --- a/src/lib/onboard/model-router.ts +++ b/src/lib/onboard/model-router.ts @@ -14,6 +14,7 @@ import { } from "../credentials/store"; import { ROOT, run, runCapture } from "../runner"; import { hashCredential } from "../security/credential-hash"; +import { rejectSymlinksOnPath } from "../state/config-io"; import type { Session } from "../state/onboard-session"; import * as onboardSession from "../state/onboard-session"; import { nemoclawStateRoot } from "../state/state-root"; @@ -232,7 +233,9 @@ export async function startModelRouter( const stateDir = path.join(nemoclawStateRoot(deps.homeDir, GATEWAY_PORT), "state"); const litellmConfigPath = path.join(stateDir, "litellm-proxy.yaml"); + rejectSymlinksOnPath(stateDir); deps.mkdirSync(stateDir); + rejectSymlinksOnPath(stateDir); const proxyConfigResult = deps.runProxyConfig( routerCommand, diff --git a/test/onboard-model-router.test.ts b/test/onboard-model-router.test.ts index f4ecc524e1f..222a108ce9c 100644 --- a/test/onboard-model-router.test.ts +++ b/test/onboard-model-router.test.ts @@ -434,6 +434,76 @@ describe("onboard Model Router setup", () => { assert.equal(proxyArgs[0]?.[2], expectedConfig); }); + it.each([ + ["gateways", [".nemoclaw", "gateways"]], + ["selected port", [".nemoclaw", "gateways", "9123"]], + ["state", [".nemoclaw", "gateways", "9123", "state"]], + ] as const)("rejects a symlinked %s path before generating router config", async (_label, parts) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-symlink-")); + tempDirs.add(tmpDir); + const homeDir = path.join(tmpDir, "home"); + const controlled = path.join(tmpDir, "controlled"); + const symlinkPath = path.join(homeDir, ...parts); + fs.mkdirSync(path.dirname(symlinkPath), { recursive: true }); + fs.mkdirSync(controlled); + fs.symlinkSync(controlled, symlinkPath, "dir"); + vi.stubEnv("HOME", homeDir); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "9123"); + vi.resetModules(); + const freshModelRouter = await import("../src/lib/onboard/model-router"); + const runProxyConfig = vi.fn(() => ({ status: 0 })); + + await assert.rejects( + freshModelRouter.startModelRouter( + { port: 45_680, pool_config_path: "router/test-pool.yaml" }, + { + rootDir: path.join(tmpDir, "repo"), + homeDir, + ensureModelRouterCommand: () => "/test/model-router", + runProxyConfig, + }, + ), + /symbolic link/i, + ); + + assert.equal(runProxyConfig.mock.calls.length, 0); + assert.deepEqual(fs.readdirSync(controlled), []); + }); + + it("revalidates the state directory after creation before generating router config", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-race-")); + tempDirs.add(tmpDir); + const homeDir = path.join(tmpDir, "home"); + const controlled = path.join(tmpDir, "controlled"); + const stateDir = path.join(homeDir, ".nemoclaw", "gateways", "9123", "state"); + fs.mkdirSync(controlled, { recursive: true }); + vi.stubEnv("HOME", homeDir); + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "9123"); + vi.resetModules(); + const freshModelRouter = await import("../src/lib/onboard/model-router"); + const runProxyConfig = vi.fn(() => ({ status: 0 })); + + await assert.rejects( + freshModelRouter.startModelRouter( + { port: 45_681, pool_config_path: "router/test-pool.yaml" }, + { + rootDir: path.join(tmpDir, "repo"), + homeDir, + ensureModelRouterCommand: () => "/test/model-router", + mkdirSync: () => { + fs.mkdirSync(path.dirname(stateDir), { recursive: true }); + fs.symlinkSync(controlled, stateDir, "dir"); + }, + runProxyConfig, + }, + ), + /symbolic link/i, + ); + + assert.equal(runProxyConfig.mock.calls.length, 0); + assert.deepEqual(fs.readdirSync(controlled), []); + }); + it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", () => { const pathCommand = "/tmp/path-model-router"; const harness = createCommandHarness({ pathCommand }); From 70c8d2ddae84eb710fc809e6e88b35599ea4a5ce Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 10:51:17 -0700 Subject: [PATCH 11/28] test(e2e): assert port-scoped gateway registries Signed-off-by: Prekshi Vyas --- .../e2e/live/concurrent-gateway-ports.test.ts | 40 ++++++++++++++----- test/repro-2666-silent-list-status.test.ts | 6 ++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/test/e2e/live/concurrent-gateway-ports.test.ts b/test/e2e/live/concurrent-gateway-ports.test.ts index e5e3f39ac81..0d72f5ecbf8 100644 --- a/test/e2e/live/concurrent-gateway-ports.test.ts +++ b/test/e2e/live/concurrent-gateway-ports.test.ts @@ -5,8 +5,8 @@ * * Preserves the real-system boundaries: two NemoClaw onboards on one * host, per-port OpenShell Docker-driver gateways, dashboard forward - * allocation, `nemoclaw list`, OpenShell sandbox discovery, host socket probes, - * and destroy/health cleanup. + * allocation, port-scoped `nemoclaw list`, OpenShell sandbox discovery, host + * socket probes, and destroy/health cleanup. */ import fs from "node:fs"; @@ -325,6 +325,7 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and "sandbox A onboards on the default NemoClaw gateway and dashboard port", "sandbox B onboards with NEMOCLAW_GATEWAY_PORT on a non-default gateway", "both sandboxes, gateways, and dashboard forwards coexist without port collision", + "each port-scoped registry lists only the sandbox owned by that gateway", "destroying sandbox B leaves sandbox A healthy on the default gateway", ], gatewayA, @@ -382,6 +383,7 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and const listAfterA = await command(host, ["list"], { artifactName: "phase-1-nemoclaw-list-after-a", + env: commandEnv({ NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT_A }), timeoutMs: 60_000, }); expect(listAfterA.exitCode, resultText(listAfterA)).toBe(0); @@ -415,17 +417,28 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and await expectPortListening(host, GATEWAY_PORT_A, "phase-3-gateway-port-a-still-listening"); await expectPortListening(host, GATEWAY_PORT_B, "phase-3-gateway-port-b-listening"); - const listBoth = await command(host, ["list"], { - artifactName: "phase-3-nemoclaw-list-both-sandboxes", + const listGatewayA = await command(host, ["list"], { + artifactName: "phase-3-nemoclaw-list-gateway-a", + env: commandEnv({ NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT_A }), timeoutMs: 60_000, }); - expect(listBoth.exitCode, resultText(listBoth)).toBe(0); - expect(outputIncludesSandbox(listBoth.stdout, SANDBOX_A), listBoth.stdout).toBe(true); - expect(outputIncludesSandbox(listBoth.stdout, SANDBOX_B), listBoth.stdout).toBe(true); - const dashboardAAfterB = dashboardPortFromList(listBoth.stdout, SANDBOX_A); - const dashboardB = dashboardPortFromList(listBoth.stdout, SANDBOX_B); - expect(dashboardAAfterB, listBoth.stdout).toBe(dashboardA); - expect(dashboardB, listBoth.stdout).toBeTruthy(); + expect(listGatewayA.exitCode, resultText(listGatewayA)).toBe(0); + expect(outputIncludesSandbox(listGatewayA.stdout, SANDBOX_A), listGatewayA.stdout).toBe(true); + expect(outputIncludesSandbox(listGatewayA.stdout, SANDBOX_B), listGatewayA.stdout).toBe(false); + + const listGatewayB = await command(host, ["list"], { + artifactName: "phase-3-nemoclaw-list-gateway-b", + env: commandEnv({ NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT_B }), + timeoutMs: 60_000, + }); + expect(listGatewayB.exitCode, resultText(listGatewayB)).toBe(0); + expect(outputIncludesSandbox(listGatewayB.stdout, SANDBOX_B), listGatewayB.stdout).toBe(true); + expect(outputIncludesSandbox(listGatewayB.stdout, SANDBOX_A), listGatewayB.stdout).toBe(false); + + const dashboardAAfterB = dashboardPortFromList(listGatewayA.stdout, SANDBOX_A); + const dashboardB = dashboardPortFromList(listGatewayB.stdout, SANDBOX_B); + expect(dashboardAAfterB, listGatewayA.stdout).toBe(dashboardA); + expect(dashboardB, listGatewayB.stdout).toBeTruthy(); expect(dashboardB).not.toBe(dashboardA); const destroyB = await command(host, [SANDBOX_B, "destroy", "--yes"], { @@ -451,6 +464,11 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and sandboxBOnboarded: onboardB.exitCode === 0, sandboxAPreserved: ["Ready", "Running"].includes(phaseAAfterB), sandboxBReady: ["Ready", "Running"].includes(phaseBAfterB), + registryScopesIsolated: + outputIncludesSandbox(listGatewayA.stdout, SANDBOX_A) && + !outputIncludesSandbox(listGatewayA.stdout, SANDBOX_B) && + outputIncludesSandbox(listGatewayB.stdout, SANDBOX_B) && + !outputIncludesSandbox(listGatewayB.stdout, SANDBOX_A), dashboardPortsDistinct: Boolean(dashboardA && dashboardB && dashboardA !== dashboardB), sandboxAPreservedAfterDestroyB: ["Ready", "Running"].includes(phaseAAfterDestroyB), }, diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index fff5f79ff2b..c417d2dad2f 100644 --- a/test/repro-2666-silent-list-status.test.ts +++ b/test/repro-2666-silent-list-status.test.ts @@ -291,8 +291,8 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress it("nemoclaw list reads the registry scoped to a non-default gateway port (#3053)", () => { const port = 9123; - fs.rmSync(path.join(home, ".nemoclaw", "sandboxes.json"), { force: true }); - seedRegistry(nemoclawStateRoot(home, port)); + seedRegistry(path.join(home, ".nemoclaw"), "default-root-model"); + seedRegistry(nemoclawStateRoot(home, port), "selected-port-model"); const { code, stdout, stderr } = runCli(["list"], { NEMOCLAW_GATEWAY_PORT: String(port), @@ -301,6 +301,8 @@ describe("simulated container-stopped and foreign-port-holder subprocess regress expect(code).toBe(0); expect(combined).toContain("my-assist"); + expect(combined).toContain("selected-port-model"); + expect(combined).not.toContain("default-root-model"); }); it( From d2d7483c3afbd8f2fe9ac2bf33251239f8f1a580 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 12:44:49 -0700 Subject: [PATCH 12/28] fix(state): close multi-gateway lifecycle gaps Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/lifecycle.mdx | 4 +- docs/reference/architecture.mdx | 2 +- scripts/install.sh | 131 +++++- src/commands/internal/uninstall/run-plan.ts | 7 +- .../openrouter-runtime-adapter-cleanup.ts | 6 + .../run-plan-gateway-segregation.test.ts | 245 +++++++++++- src/lib/actions/uninstall/run-plan.test.ts | 17 +- src/lib/actions/uninstall/run-plan.ts | 374 +++++++++++++++--- src/lib/cli/public-dispatch.ts | 26 ++ src/lib/domain/uninstall/paths.ts | 9 +- src/lib/onboard/dashboard-port.test.ts | 53 ++- src/lib/onboard/dashboard-port.ts | 58 ++- src/lib/state/gateway-registry.test.ts | 96 +++++ src/lib/state/gateway-registry.ts | 243 ++++++++++++ src/lib/state/legacy-port-migration.test.ts | 179 +++++++++ src/lib/state/legacy-port-migration.ts | 359 +++++++++++++++++ .../e2e/live/concurrent-gateway-ports.test.ts | 54 ++- test/install-gateway-state-root.test.ts | 158 ++++++++ 18 files changed, 1892 insertions(+), 129 deletions(-) create mode 100644 src/lib/state/gateway-registry.test.ts create mode 100644 src/lib/state/gateway-registry.ts create mode 100644 src/lib/state/legacy-port-migration.test.ts create mode 100644 src/lib/state/legacy-port-migration.ts create mode 100644 test/install-gateway-state-root.test.ts diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index 52f1d06e421..d7e3f7c29a6 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -147,14 +147,14 @@ When the default API port is already held by another sandbox, `$$nemoclaw onboar If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location, and `$$nemoclaw uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location. When other ports remain, `$$nemoclaw uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. Gateway and dashboard cleanup is scoped by sandbox name and port. A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward. If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location, and `nemo-deepagents uninstall` on one port leaves the other ports' state in place. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location. When other ports remain, `nemo-deepagents uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. Gateway cleanup is scoped by sandbox name and port. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index e7c4f2f6b99..2e53b7b3e8b 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -308,7 +308,7 @@ NemoClaw keeps non-secret operator-facing state on the host rather than inside t | Path | Purpose | |---|---| | `~/.nemoclaw/sandboxes.json` | Registered sandbox metadata for the default gateway port, including the default sandbox selection. | -| `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, snapshots, and legacy credential-migration files) for a non-default `NEMOCLAW_GATEWAY_PORT`. Provider credentials remain in the OpenShell gateway store. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | +| `~/.nemoclaw/gateways//` | Segregated host state root (its own registry, snapshots, and legacy credential-migration files) for a non-default `NEMOCLAW_GATEWAY_PORT`. On upgrade, rows and related state move out of the legacy shared root only when their recorded gateway identity matches the selected port. Provider credentials remain in the OpenShell gateway store. The default gateway port uses the top-level `~/.nemoclaw/` location, so existing single-gateway hosts are unchanged. | | `~/.openclaw/openclaw.json` | Host OpenClaw configuration that NemoClaw snapshots or restores during migration flows. | diff --git a/scripts/install.sh b/scripts/install.sh index eb0f6f5bb31..ef241464fb7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -193,6 +193,36 @@ error() { } ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"; } +resolve_nemoclaw_gateway_port() { + local port="${NEMOCLAW_GATEWAY_PORT:-8080}" + port="${port#"${port%%[![:space:]]*}"}" + port="${port%"${port##*[![:space:]]}"}" + if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then + error "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535." + fi + printf "%s" "$port" +} + +nemoclaw_state_dir() { + local port + port="$(resolve_nemoclaw_gateway_port)" + if [ "$port" -eq 8080 ]; then + printf "%s/.nemoclaw" "$HOME" + else + printf "%s/.nemoclaw/gateways/%s" "$HOME" "$port" + fi +} + +nemoclaw_gateway_name() { + local port + port="$(resolve_nemoclaw_gateway_port)" + if [ "$port" -eq 8080 ]; then + printf "nemoclaw" + else + printf "nemoclaw-%s" "$port" + fi +} + # Common TTY-required error message for the third-party software notice. # Used by both show_usage_notice() and preflight_usage_notice_prompt() so # the recovery hint stays in sync (#3058). @@ -243,13 +273,15 @@ verify_downloaded_script() { } resolve_default_sandbox_name() { - local registry_file="${HOME}/.nemoclaw/sandboxes.json" + local state_dir registry_file + state_dir="$(nemoclaw_state_dir)" + registry_file="${state_dir}/sandboxes.json" local sandbox_name="" # Prefer the sandbox name from the current onboard session — it reflects # the sandbox just created, whereas sandboxes.json may hold a stale default # from a previous gateway that no longer exists (#1839). - local session_file="${HOME}/.nemoclaw/onboard-session.json" + local session_file="${state_dir}/onboard-session.json" if [[ -f "$session_file" ]] && command_exists node; then sandbox_name="$( node -e ' @@ -302,7 +334,8 @@ resolve_default_sandbox_name() { } resolve_onboarded_agent() { - local session_file="${HOME}/.nemoclaw/onboard-session.json" + local session_file + session_file="$(nemoclaw_state_dir)/onboard-session.json" if [[ -f "$session_file" ]] && command_exists node; then node -e ' const fs = require("fs"); @@ -335,7 +368,7 @@ restore_onboard_forward_after_post_checks() { return 0 fi - state_dir="${HOME}/.nemoclaw/state" + state_dir="$(nemoclaw_state_dir)/state" mkdir -p "$state_dir" 2>/dev/null || true pid_file="${state_dir}/${agent_name}-${sandbox_name}-${port}.forward.pid" if [[ -f "$pid_file" ]]; then @@ -725,7 +758,7 @@ json_string_field() { } usage_notice_state_file() { - printf "%s/.nemoclaw/usage-notice.json" "${HOME}" + printf "%s/usage-notice.json" "$(nemoclaw_state_dir)" } usage_notice_accepted_shell() { @@ -1703,8 +1736,9 @@ verify_nemoclaw() { } inspect_sandbox_registry_for_upgrade() { - local reg_file="$1" field="$2" - node - "$reg_file" "$field" <<'NODE' + local reg_file="$1" field="$2" scope="${3:-legacy}" gateway_port + gateway_port="$(resolve_nemoclaw_gateway_port)" + node - "$reg_file" "$field" "$gateway_port" "$scope" <<'NODE' const fs = require("node:fs"); function isObjectRecord(value) { @@ -1719,10 +1753,44 @@ try { } if (!isObjectRecord(registry) || !isObjectRecord(registry.sandboxes)) process.exit(1); -const entries = Object.entries(registry.sandboxes); -if (entries.some(([name, entry]) => !name.trim() || !isObjectRecord(entry) || entry.name !== name)) { +const allEntries = Object.entries(registry.sandboxes); +if (allEntries.some(([name, entry]) => !name.trim() || !isObjectRecord(entry) || entry.name !== name)) { + process.exit(1); +} + +const selectedPort = Number(process.argv[4]); +const canonicalName = (port) => port === 8080 ? "nemoclaw" : `nemoclaw-${port}`; +const portFromName = (name) => { + if (name === "nemoclaw") return 8080; + const match = /^nemoclaw-([0-9]+)$/.exec(name); + if (!match) return null; + const port = Number(match[1]); + return Number.isInteger(port) && port >= 1 && port <= 65535 && canonicalName(port) === name + ? port + : null; +}; +const entryPort = (entry) => { + const hasPort = entry.gatewayPort !== undefined && entry.gatewayPort !== null; + const hasName = entry.gatewayName !== undefined && entry.gatewayName !== null; + if (hasPort && (!Number.isInteger(entry.gatewayPort) || entry.gatewayPort < 1 || entry.gatewayPort > 65535)) { + throw new Error("invalid gatewayPort"); + } + if (hasName && typeof entry.gatewayName !== "string") throw new Error("invalid gatewayName"); + const namedPort = hasName ? portFromName(entry.gatewayName) : null; + if (hasName && namedPort === null) throw new Error("invalid gatewayName"); + if (hasPort && hasName && canonicalName(entry.gatewayPort) !== entry.gatewayName) { + throw new Error("conflicting gateway identity"); + } + return hasPort ? entry.gatewayPort : namedPort ?? 8080; +}; + +let entries; +try { + entries = allEntries.filter(([, entry]) => entryPort(entry) === selectedPort); +} catch { process.exit(1); } +if (process.argv[5] === "selected" && entries.length !== allEntries.length) process.exit(1); if (process.argv[3] === "count") { process.stdout.write(String(entries.length)); @@ -1745,12 +1813,19 @@ NODE } registered_sandbox_count() { - local reg_file="${HOME}/.nemoclaw/sandboxes.json" + local reg_file scope="selected" + reg_file="$(nemoclaw_state_dir)/sandboxes.json" + if [ "$(resolve_nemoclaw_gateway_port)" -eq 8080 ]; then scope="legacy"; fi + if [ ! -f "$reg_file" ] && [ "$(resolve_nemoclaw_gateway_port)" -ne 8080 ]; then + # Pre-segregation releases stored every gateway's rows in the shared file. + reg_file="${HOME}/.nemoclaw/sandboxes.json" + scope="legacy" + fi if [ ! -f "$reg_file" ]; then printf "0" return fi - inspect_sandbox_registry_for_upgrade "$reg_file" count + inspect_sandbox_registry_for_upgrade "$reg_file" count "$scope" } resolve_existing_cli_runner() { @@ -1834,7 +1909,12 @@ installer_non_interactive() { legacy_ambiguous_sandbox_names_json() { local reg_file="$1" - inspect_sandbox_registry_for_upgrade "$reg_file" ambiguous-names + local scope="legacy" + if [ "$(resolve_nemoclaw_gateway_port)" -ne 8080 ] \ + && [ "$reg_file" = "$(nemoclaw_state_dir)/sandboxes.json" ]; then + scope="selected" + fi + inspect_sandbox_registry_for_upgrade "$reg_file" ambiguous-names "$scope" } normalize_legacy_managed_confirmation_json() { @@ -2001,8 +2081,13 @@ EOF } preinstall_backup_and_retire_legacy_gateway() { - local reg_file="${HOME}/.nemoclaw/sandboxes.json" + local reg_file gateway_name + reg_file="$(nemoclaw_state_dir)/sandboxes.json" + if [ ! -f "$reg_file" ] && [ "$(resolve_nemoclaw_gateway_port)" -ne 8080 ]; then + reg_file="${HOME}/.nemoclaw/sandboxes.json" + fi [ -f "$reg_file" ] || return 0 + gateway_name="$(nemoclaw_gateway_name)" local sandbox_count if ! sandbox_count="$(registered_sandbox_count)"; then @@ -2046,9 +2131,14 @@ preinstall_backup_and_retire_legacy_gateway() { # Retire the old gateway while the old CLI can still do it, after backup. if [[ -n "$old_openshell_version" ]] && ! version_gte "$old_openshell_version" "0.0.37"; then info "Retiring OpenShell ${old_openshell_version} gateway before installing current OpenShell…" - openshell gateway destroy -g nemoclaw >/dev/null 2>&1 \ - || openshell gateway destroy >/dev/null 2>&1 \ - || warn "Could not destroy the legacy OpenShell gateway before upgrade; onboarding will clean up stale runtime state." + if [ "$gateway_name" = "nemoclaw" ]; then + openshell gateway destroy -g "$gateway_name" >/dev/null 2>&1 \ + || openshell gateway destroy >/dev/null 2>&1 \ + || warn "Could not destroy the legacy OpenShell gateway before upgrade; onboarding will clean up stale runtime state." + else + openshell gateway destroy -g "$gateway_name" >/dev/null 2>&1 \ + || warn "Could not destroy legacy gateway ${gateway_name} before upgrade; onboarding will clean up only that gateway's stale runtime state." + fi fi } @@ -2334,7 +2424,8 @@ run_onboard() { show_usage_notice info "Running ${_CLI_BIN} onboard…" local -a onboard_cmd=(onboard) - local session_file="${HOME}/.nemoclaw/onboard-session.json" + local session_file + session_file="$(nemoclaw_state_dir)/onboard-session.json" # --fresh takes precedence over any session state. We forward --fresh to # the active CLI's onboard command so it clears the existing session file before # creating a new one — the install.sh classifier is bypassed entirely. @@ -2891,7 +2982,9 @@ main() { step 3 "Onboarding" if [ -n "$_cli_runner" ]; then - if [[ -f "${HOME}/.nemoclaw/sandboxes.json" ]] && node -e ' + local selected_registry="" + selected_registry="$(nemoclaw_state_dir)/sandboxes.json" + if [[ -f "$selected_registry" ]] && node -e ' const fs = require("fs"); try { const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); @@ -2900,7 +2993,7 @@ main() { } catch { process.exit(1); } - ' "${HOME}/.nemoclaw/sandboxes.json"; then + ' "$selected_registry"; then warn "Existing sandbox sessions detected. Onboarding may disrupt running agents." if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then error "Aborting — NEMOCLAW_SINGLE_SESSION is set. Destroy existing sessions with '${_CLI_BIN} destroy' before reinstalling." diff --git a/src/commands/internal/uninstall/run-plan.ts b/src/commands/internal/uninstall/run-plan.ts index aad6acc5637..d319246818c 100644 --- a/src/commands/internal/uninstall/run-plan.ts +++ b/src/commands/internal/uninstall/run-plan.ts @@ -5,6 +5,8 @@ import { Flags } from "@oclif/core"; import { runUninstallPlan } from "../../../lib/actions/uninstall/run-plan"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../../lib/cli/branding"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { GATEWAY_PORT } from "../../../lib/core/ports"; +import { resolveGatewayName } from "../../../lib/onboard/gateway-binding"; export default class InternalUninstallRunPlanCommand extends NemoClawCommand { static hidden = true; @@ -25,7 +27,10 @@ export default class InternalUninstallRunPlanCommand extends NemoClawCommand { description: "Also remove preserved user data under ~/.nemoclaw/ (rebuild-backups/, backups/, sandboxes.json)", }), - gateway: Flags.string({ description: "Gateway name", default: "nemoclaw" }), + gateway: Flags.string({ + description: "Gateway name", + default: resolveGatewayName(GATEWAY_PORT), + }), }; public async run(): Promise { diff --git a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts index 53d4beb0254..0c248b73c05 100644 --- a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts +++ b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts @@ -104,6 +104,7 @@ function tryStopOpenRouterRuntimeAdapterPid( export function stopOpenRouterRuntimeAdapter( paths: Pick, runtime: OpenRouterRuntimeAdapterCleanupRuntime, + options: { scanOrphans?: boolean } = {}, ): void { const stopped = new Set(); @@ -120,6 +121,11 @@ export function stopOpenRouterRuntimeAdapter( } } + if (options.scanOrphans === false) { + if (stopped.size === 0) runtime.log("No selected-gateway OpenRouter Runtime adapter found"); + return; + } + if (!runtime.commandExists("lsof")) { if (stopped.size === 0) { runtime.warn("lsof not found; skipping orphan OpenRouter Runtime adapter scan."); 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 fa40e4a08c9..41512d83fd9 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -25,13 +25,19 @@ describe("uninstall gateway-port segregation (#3053)", () => { const stateDir = path.join(tmpHome, ".nemoclaw"); const otherEnv = path.join(stateDir, "gateways", "8091"); fs.mkdirSync(otherEnv, { recursive: true }); - fs.writeFileSync(path.join(otherEnv, "sandboxes.json"), "[]"); - fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync( + path.join(otherEnv, "sandboxes.json"), + JSON.stringify({ defaultSandbox: null, sandboxes: {} }), + ); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ defaultSandbox: null, sandboxes: {} }), + ); const logs: string[] = []; const result = runUninstallPlan( { assumeYes: true, deleteModels: false, keepOpenShell: true }, { - commandExists: () => false, + commandExists: (command) => command === "openshell", env: { HOME: tmpHome, NEMOCLAW_NON_INTERACTIVE: "", @@ -102,7 +108,10 @@ describe("uninstall gateway-port segregation (#3053)", () => { const selectedEnv = path.join(stateDir, "gateways", String(port)); fs.mkdirSync(selectedEnv, { recursive: true }); fs.mkdirSync(path.join(stateDir, "backups")); - fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ defaultSandbox: null, sandboxes: {} }), + ); fs.writeFileSync(path.join(stateDir, "managed_swap"), "/swapfile"); const defaultSession = path.join(stateDir, "onboard-session.json"); fs.writeFileSync(defaultSession, "{}"); @@ -194,4 +203,232 @@ describe("uninstall gateway-port segregation (#3053)", () => { fs.rmSync(tmpHome, { recursive: true, force: true }); } }); + + it("treats a populated default registry as a sibling even without a default session", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-default-registry-")); + const port = 9123; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(port)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const shared = path.join(tmpHome, ".nemoclaw"); + const selected = path.join(shared, "gateways", String(port)); + fs.mkdirSync(selected, { recursive: true }); + fs.writeFileSync( + path.join(shared, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "default-box", + sandboxes: { + "default-box": { + name: "default-box", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + }, + }), + ); + fs.writeFileSync(path.join(shared, "managed_swap"), "/swapfile"); + const runCalls: string[][] = []; + + const result = runPortUninstall( + { + assumeYes: true, + deleteModels: false, + destroyUserData: true, + gatewayName: `nemoclaw-${String(port)}`, + keepOpenShell: true, + }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_GATEWAY_PORT: String(port) } as NodeJS.ProcessEnv, + existsSync: (target) => + target === "/swapfile" || (target.startsWith(tmpHome) && fs.existsSync(target)), + isTty: true, + log: vi.fn(), + run: (_command, args) => { + runCalls.push(args); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(runCalls).not.toContainEqual(["swapoff", "/swapfile"]); + expect(fs.existsSync(path.join(shared, "managed_swap"))).toBe(true); + expect(fs.existsSync(path.join(shared, "sandboxes.json"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("uninstalls only the selected gateway while preserving host-shared and default resources", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-selected-only-")); + const port = 9123; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(port)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const shared = path.join(tmpHome, ".nemoclaw"); + const selected = path.join(shared, "gateways", String(port)); + const openshellConfig = path.join(tmpHome, ".config", "openshell"); + const nemoclawConfig = path.join(tmpHome, ".config", "nemoclaw"); + fs.mkdirSync(selected, { recursive: true }); + fs.mkdirSync(openshellConfig, { recursive: true }); + fs.mkdirSync(nemoclawConfig, { recursive: true }); + fs.writeFileSync(path.join(openshellConfig, "keep"), "default"); + fs.writeFileSync(path.join(nemoclawConfig, "keep"), "default"); + fs.writeFileSync( + path.join(shared, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "default-box", + sandboxes: { + "default-box": { + name: "default-box", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + }, + }), + ); + fs.writeFileSync( + path.join(selected, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "port-box", + sandboxes: { + "port-box": { + name: "port-box", + gatewayName: `nemoclaw-${String(port)}`, + gatewayPort: port, + }, + }, + }), + ); + + const runCalls: Array<{ command: string; args: string[] }> = []; + const dockerCalls: string[][] = []; + const dockerOutputByCommand: Record = { + images: "shared-image nemoclaw:latest", + ps: [ + "default-id image openshell-cluster-nemoclaw", + `selected-id image openshell-cluster-nemoclaw-${String(port)}`, + ].join("\n"), + }; + const result = runPortUninstall( + { + assumeYes: true, + deleteModels: true, + destroyUserData: true, + gatewayName: `nemoclaw-${String(port)}`, + keepOpenShell: false, + }, + { + commandExists: (command) => ["docker", "npm", "ollama", "openshell"].includes(command), + env: { HOME: tmpHome, NEMOCLAW_GATEWAY_PORT: String(port) } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: vi.fn(), + run: (command, args) => { + runCalls.push({ command, args }); + return ok(); + }, + runDocker: (args) => { + dockerCalls.push(args); + return ok(dockerOutputByCommand[args[0]] ?? ""); + }, + }, + ); + + expect(result.exitCode).toBe(0); + const openshellCalls = runCalls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "select", `nemoclaw-${String(port)}`]); + expect(openshellCalls).toContainEqual(["sandbox", "delete", "port-box"]); + expect(openshellCalls).toContainEqual([ + "gateway", + "destroy", + "-g", + `nemoclaw-${String(port)}`, + ]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(openshellCalls.some((args) => args[0] === "provider")).toBe(false); + expect(runCalls.some(({ command }) => command === "npm" || command === "ollama")).toBe(false); + expect(dockerCalls).toContainEqual(["rm", "-f", "selected-id"]); + expect(dockerCalls).not.toContainEqual(["rm", "-f", "default-id"]); + expect(dockerCalls.some((args) => args[0] === "rmi")).toBe(false); + expect(fs.existsSync(selected)).toBe(false); + expect(fs.existsSync(path.join(shared, "sandboxes.json"))).toBe(true); + expect(fs.existsSync(path.join(openshellConfig, "keep"))).toBe(true); + expect(fs.existsSync(path.join(nemoclawConfig, "keep"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("preserves selected state when the owning gateway cannot be selected", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-select-fail-")); + const port = 9123; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(port)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const shared = path.join(tmpHome, ".nemoclaw"); + const selected = path.join(shared, "gateways", String(port)); + fs.mkdirSync(selected, { recursive: true }); + fs.writeFileSync( + path.join(shared, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "default-box", + sandboxes: { "default-box": { name: "default-box" } }, + }), + ); + fs.writeFileSync( + path.join(selected, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "port-box", + sandboxes: { + "port-box": { + name: "port-box", + gatewayName: `nemoclaw-${String(port)}`, + gatewayPort: port, + }, + }, + }), + ); + const calls: string[][] = []; + + const result = runPortUninstall( + { + assumeYes: true, + deleteModels: false, + destroyUserData: true, + gatewayName: `nemoclaw-${String(port)}`, + keepOpenShell: false, + }, + { + commandExists: (command) => command === "openshell", + env: { HOME: tmpHome, NEMOCLAW_GATEWAY_PORT: String(port) } as NodeJS.ProcessEnv, + error: vi.fn(), + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: vi.fn(), + run: (_command, args) => { + calls.push(args); + return args[0] === "gateway" && args[1] === "select" + ? { status: 1, stdout: "", stderr: "unreachable" } + : ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(1); + expect(calls).toEqual([["gateway", "select", `nemoclaw-${String(port)}`]]); + expect(fs.existsSync(path.join(selected, "sandboxes.json"))).toBe(true); + expect(fs.existsSync(path.join(shared, "sandboxes.json"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); }); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index bc9885e7dc2..04da5ee3bfd 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -981,7 +981,13 @@ describe("uninstall run plan", () => { ); fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true }); fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello"); - fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "sb1", + sandboxes: { sb1: { name: "sb1", gatewayName: "nemoclaw", gatewayPort: 8080 } }, + }), + ); fs.writeFileSync(path.join(stateDir, "ollama-auth-proxy.pid"), "1234"); fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.pid"), "1235"); fs.writeFileSync(path.join(stateDir, "openrouter-runtime-adapter.json"), "{}"); @@ -1294,7 +1300,7 @@ describe("uninstall run plan", () => { } }); - it("exits non-zero and warns when lstat on ~/.nemoclaw fails with a non-ENOENT error", () => { + it("fails closed before cleanup when ~/.nemoclaw cannot be inspected", () => { const { tmpHome, stateDir } = setupStateDir(); const realLstat = fs.lstatSync; const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((p: fs.PathLike) => { @@ -1317,12 +1323,7 @@ describe("uninstall run plan", () => { ); expect(result.exitCode).toBe(1); - expect(warnings.some((line) => line.startsWith(`Failed to inspect ${stateDir}: `))).toBe( - true, - ); - expect(warnings).toContain( - "Uninstall completed with errors. Some state may remain on disk; see warnings above.", - ); + expect(warnings.some((line) => line.includes("permission denied"))).toBe(true); expect(logs).not.toContain("Claws retracted. Until next time."); expect( fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index ac728488ccf..b1ae22b5cda 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { type AgentBranding, getAgentBranding } from "../../cli/branding"; +import { DEFAULT_GATEWAY_PORT, GATEWAY_PORT } from "../../core/ports"; import { isStdinTty, readLineFromStdin } from "../../core/stdin"; import { sleepMs } from "../../core/wait"; import { @@ -23,9 +24,11 @@ import { type UninstallPaths, } from "../../domain/uninstall/paths"; import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan"; +import { resolveGatewayName } from "../../onboard/gateway-binding"; import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process"; import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup"; +import { readGatewayRegistryFile, registryEntryGatewayPort } from "../../state/gateway-registry"; import { GATEWAYS_SUBDIR } from "../../state/state-root"; import { stopOpenRouterRuntimeAdapter } from "./openrouter-runtime-adapter-cleanup"; import { classifyShimPath, type FileSystemDeps } from "./plan"; @@ -110,6 +113,16 @@ function splitNonEmptyLines(output: string): string[] { .filter(Boolean); } +function pathEntryExists(target: string, runtime: Pick): boolean { + try { + fs.lstatSync(target); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return runtime.existsSync(target); + return true; + } +} + function globToRegExp(pattern: string): RegExp { return new RegExp( `^${path @@ -288,10 +301,18 @@ function planStepDisplayName(stepName: string, branding: AgentBranding): string return stepName === "NemoClaw CLI" ? `${branding.display} CLI` : stepName; } -function printBanner(runtime: UninstallRuntime): void { +function printBanner( + runtime: UninstallRuntime, + scopedToSelectedGateway = false, + gatewayName = resolveGatewayName(GATEWAY_PORT), +): void { const branding = runtimeBranding(runtime); runtime.log(`${branding.display} Uninstaller`); - runtime.log(`This will remove all ${branding.display} resources.`); + runtime.log( + scopedToSelectedGateway + ? `This will remove ${branding.display} resources owned by gateway '${gatewayName}'.` + : `This will remove all ${branding.display} resources.`, + ); } function printBye(runtime: UninstallRuntime): void { @@ -325,12 +346,23 @@ function confirm( options: UninstallRunOptions, runtime: UninstallRuntime, paths: UninstallPaths, + scopedToSelectedGateway: boolean, ): boolean { const branding = runtimeBranding(runtime); if (options.assumeYes) return true; runtime.log("What will be removed:"); - runtime.log(` · All OpenShell sandboxes, gateway, and ${branding.display} providers`); - runtime.log(" · Related Docker containers, images, and volumes"); + if (scopedToSelectedGateway) { + runtime.log( + ` · Sandboxes and gateway '${options.gatewayName || resolveGatewayName(GATEWAY_PORT)}' for port ${String(GATEWAY_PORT)}`, + ); + runtime.log(" · Selected gateway state and Docker volume"); + runtime.log( + ` · Shared ${branding.display} CLI, services, images, providers, and config: kept`, + ); + } else { + runtime.log(` · All OpenShell sandboxes, gateway, and ${branding.display} providers`); + runtime.log(" · Related Docker containers, images, and volumes"); + } runtime.log(userDataDispositionLine(options, runtime, paths)); runtime.log(" · ~/.config/openshell ~/.config/nemoclaw"); runtime.log(` · Global ${branding.display} CLI (npm package: nemoclaw)`); @@ -357,11 +389,11 @@ function runOptional( command: string, args: string[], opts: { onSkip?: string } = {}, -): void { +): boolean { const result = runtime.run(command, args, { env: runtime.env, stdio: "ignore" }); if (result.status === 0) { runtime.log(description); - return; + return true; } // #3456 sub-bug #4: when the destroy/delete call no-ops (target already // gone), printing ` skipped` was self-contradictory — e.g. @@ -369,6 +401,7 @@ function runOptional( // destroyed AND skipped. Callers that care can pass a `onSkip` message // describing the actual state (target absent or unreachable). runtime.warn(opts.onSkip ?? `${description} skipped`); + return false; } function stopHelperServices(paths: UninstallPaths, runtime: UninstallRuntime): void { @@ -472,7 +505,11 @@ function tryStopOllamaProxyPid(pid: number, runtime: UninstallRuntime): boolean return false; } -function stopOllamaAuthProxy(paths: UninstallPaths, runtime: UninstallRuntime): void { +function stopOllamaAuthProxy( + paths: UninstallPaths, + runtime: UninstallRuntime, + scanOrphans = true, +): void { // The auth proxy is a detached node child started by the Local Ollama // onboard path that listens on `NEMOCLAW_OLLAMA_PROXY_PORT` (default // 11435). Without this cleanup, @@ -501,6 +538,11 @@ function stopOllamaAuthProxy(paths: UninstallPaths, runtime: UninstallRuntime): } } + if (!scanOrphans) { + if (stopped.size === 0) runtime.log("No selected-gateway Ollama auth proxy found"); + return; + } + // 2. Fall back to the configured proxy port for orphans whose PID file is // gone (e.g. a previous uninstall already wiped state but the process // survived). Filter via cmdline so we never kill unrelated listeners. @@ -571,7 +613,11 @@ function tryStopModelRouterPid(pid: number, runtime: UninstallRuntime): boolean return false; } -function stopModelRouter(paths: UninstallPaths, runtime: UninstallRuntime): void { +function stopModelRouter( + paths: UninstallPaths, + runtime: UninstallRuntime, + scanOrphans = true, +): void { // The model router is a detached child started during routed onboard that // listens on port 4000 by default. Without this cleanup, uninstall + // reinstall fails with "Port 4000 already has a healthy router endpoint". @@ -589,6 +635,11 @@ function stopModelRouter(paths: UninstallPaths, runtime: UninstallRuntime): void if (tryStopModelRouterPid(recordedPid, runtime)) stopped.add(recordedPid); } + if (!scanOrphans) { + if (stopped.size === 0) runtime.log("No selected-gateway model router found"); + return; + } + if (!runtime.commandExists("lsof")) { if (stopped.size === 0) { runtime.warn("lsof not found; skipping orphan model router scan."); @@ -629,10 +680,78 @@ function stopOrphanedOpenShell(runtime: UninstallRuntime): void { } } -function removeOpenShellResources(options: UninstallRunOptions, runtime: UninstallRuntime): void { +function selectedRegistrySandboxNames(paths: UninstallPaths, runtime: UninstallRuntime): string[] { + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + const home = path.dirname(sharedRoot); + const registryFile = path.join(paths.nemoclawStateDir, "sandboxes.json"); + if (!pathEntryExists(registryFile, runtime)) return []; + const registry = readGatewayRegistryFile(home, registryFile); + if (!registry) return []; + const names: string[] = []; + for (const [name, entry] of Object.entries(registry.sandboxes)) { + const entryPort = registryEntryGatewayPort(entry); + if (entryPort !== GATEWAY_PORT && GATEWAY_PORT === DEFAULT_GATEWAY_PORT) { + // A pre-segregation default registry may still carry an explicitly + // non-default sibling row. Leave it for that gateway's migration. + continue; + } + if (entryPort !== GATEWAY_PORT) { + throw new Error( + `Refusing to uninstall: selected registry row ${JSON.stringify(name)} belongs to another gateway port.`, + ); + } + names.push(name); + } + return names.sort(); +} + +function removeOpenShellResources( + options: UninstallRunOptions, + runtime: UninstallRuntime, + scopedToSelectedGateway: boolean, + sandboxNames: readonly string[], +): boolean { if (!runtime.commandExists("openshell")) { runtime.warn("openshell not found; skipping gateway/provider/sandbox cleanup."); - return; + return !scopedToSelectedGateway; + } + const gatewayLabel = options.gatewayName || resolveGatewayName(GATEWAY_PORT); + if (scopedToSelectedGateway) { + const selected = runtime.run("openshell", ["gateway", "select", gatewayLabel], { + env: runtime.env, + stdio: "ignore", + }); + if (selected.status !== 0) { + runtime.warn( + `Could not select gateway '${gatewayLabel}'; refusing sandbox deletion so sibling gateways remain untouched.`, + ); + return false; + } + let removedSelectedResources = true; + for (const sandboxName of sandboxNames) { + removedSelectedResources = + runOptional( + runtime, + `Deleted OpenShell sandbox '${sandboxName}'`, + "openshell", + ["sandbox", "delete", sandboxName], + { onSkip: `OpenShell sandbox '${sandboxName}' was already absent or unreachable` }, + ) && removedSelectedResources; + } + removedSelectedResources = + runOptional( + runtime, + `Destroyed gateway '${gatewayLabel}'`, + "openshell", + ["gateway", "destroy", "-g", gatewayLabel], + { onSkip: gatewayDestroySkipMessage(gatewayLabel) }, + ) && removedSelectedResources; + if (!removedSelectedResources) { + runtime.warn("Selected gateway cleanup was incomplete; preserving its state for retry."); + return false; + } + runtime.log("Sibling gateways remain; kept shared OpenShell provider registrations."); + return true; } // #6520 sub-bug: a no-op delete must not print `Deleted … skipped`; // wording lives in domain/uninstall/messaging.ts. @@ -654,7 +773,6 @@ function removeOpenShellResources(options: UninstallRunOptions, runtime: Uninsta { onSkip: providerDeleteSkipMessage(provider) }, ); } - const gatewayLabel = options.gatewayName || "nemoclaw"; runOptional( runtime, `Destroyed gateway '${gatewayLabel}'`, @@ -662,6 +780,7 @@ function removeOpenShellResources(options: UninstallRunOptions, runtime: Uninsta ["gateway", "destroy", "-g", gatewayLabel], { onSkip: gatewayDestroySkipMessage(gatewayLabel) }, ); + return true; } function removeAliases(paths: UninstallPaths, runtime: UninstallRuntime): void { @@ -753,12 +872,22 @@ function dockerIsAvailable(runtime: UninstallRuntime): boolean { return true; } -function removeDockerContainers(runtime: UninstallRuntime): void { +function removeDockerContainers(runtime: UninstallRuntime, gatewayName?: string): void { const result = runtime.runDocker(["ps", "-a", "--format", "{{.ID}} {{.Image}} {{.Names}}"], { env: runtime.env, }); const ids = splitNonEmptyLines(result.stdout) - .filter((line) => /openshell-cluster|openshell|openclaw|nemoclaw/i.test(line)) + .filter((line) => { + if (!gatewayName) return /openshell-cluster|openshell|openclaw|nemoclaw/i.test(line); + const name = line.trim().split(/\s+/).at(-1) ?? ""; + return ( + name === `openshell-cluster-${gatewayName}` || + name === + (GATEWAY_PORT === DEFAULT_GATEWAY_PORT + ? "nemoclaw-openshell-gateway" + : `nemoclaw-openshell-gateway-${String(GATEWAY_PORT)}`) + ); + }) .map((line) => line.split(/\s+/)[0]); if (ids.length === 0) { runtime.log(`No ${runtimeBranding(runtime).display}/OpenShell Docker containers found`); @@ -824,7 +953,26 @@ function otherGatewayEnvironmentsRemain(paths: UninstallPaths, runtime: Uninstal const selectedRoot = path.resolve(paths.nemoclawStateDir); const selectedIsDefault = selectedRoot === path.resolve(sharedRoot); - if (!selectedIsDefault && runtime.existsSync(sharedRoot)) { + if (!selectedIsDefault && pathEntryExists(sharedRoot, runtime)) { + const defaultRegistryFile = path.join(sharedRoot, "sandboxes.json"); + if (pathEntryExists(defaultRegistryFile, runtime)) { + try { + const home = path.dirname(sharedRoot); + const defaultRegistry = readGatewayRegistryFile(home, defaultRegistryFile); + if ( + defaultRegistry && + Object.values(defaultRegistry.sandboxes).some( + (entry) => registryEntryGatewayPort(entry) === DEFAULT_GATEWAY_PORT, + ) + ) { + return true; + } + } catch { + // Malformed, unreadable, or symlinked shared registry state could be + // a live default environment. Preserve shared resources. + return true; + } + } try { if (fs.readdirSync(sharedRoot).some((entry) => !SHARED_HOST_STATE_ENTRIES.has(entry))) { return true; @@ -837,11 +985,17 @@ function otherGatewayEnvironmentsRemain(paths: UninstallPaths, runtime: Uninstal } const gatewaysDir = path.join(sharedRoot, GATEWAYS_SUBDIR); - if (!runtime.existsSync(gatewaysDir)) return false; + if (!pathEntryExists(gatewaysDir, runtime)) return false; try { - return fs - .readdirSync(gatewaysDir) - .some((entry) => path.resolve(gatewaysDir, entry) !== selectedRoot); + const gatewaysStat = fs.lstatSync(gatewaysDir); + if (gatewaysStat.isSymbolicLink() || !gatewaysStat.isDirectory()) return true; + return fs.readdirSync(gatewaysDir, { withFileTypes: true }).some((entry) => { + const candidate = path.resolve(gatewaysDir, entry.name); + if (candidate === selectedRoot) return false; + // Any other filesystem object is conservatively treated as gateway + // state. In particular, never follow or dismiss a symlink here. + return true; + }); } catch { // An unreadable sibling registry is still potentially live. return true; @@ -951,73 +1105,139 @@ function executePlan( options: UninstallRunOptions, runtime: UninstallRuntime, preserveUnderStateDir: readonly string[], + scopedToSelectedGateway: boolean, + sandboxNames: readonly string[], ): { ok: boolean } { let ok = true; const branding = runtimeBranding(runtime); for (const [index, step] of plan.steps.entries()) { runtime.log(`[${index + 1}/${plan.steps.length}] ${planStepDisplayName(step.name, branding)}`); if (step.name === "Stopping services") { - stopHelperServices(paths, runtime); - removeGlob(paths.helperServiceGlob, runtime); - stopMatchingPids( - `openshell.*forward.*${runtime.env.NEMOCLAW_DASHBOARD_PORT || "18789"}`, - runtime, - "local OpenShell forward processes", - ); - stopStaleDashboardListeners({ - run: runtime.run, - kill: runtime.kill, - env: runtime.env, - log: runtime.log, - warn: runtime.warn, - commandExists: runtime.commandExists, - }); - stopOrphanedOpenShell(runtime); - stopHostGatewayProcesses( - { + if (!scopedToSelectedGateway) { + stopHelperServices(paths, runtime); + removeGlob(paths.helperServiceGlob, runtime); + stopMatchingPids( + `openshell.*forward.*${runtime.env.NEMOCLAW_DASHBOARD_PORT || "18789"}`, + runtime, + "local OpenShell forward processes", + ); + stopStaleDashboardListeners({ run: runtime.run, kill: runtime.kill, env: runtime.env, log: runtime.log, warn: runtime.warn, commandExists: runtime.commandExists, - }, - { logNoProcesses: true }, - ); - stopOllamaAuthProxy(paths, runtime); - stopOpenRouterRuntimeAdapter(paths, runtime); - stopModelRouter(paths, runtime); + }); + stopOrphanedOpenShell(runtime); + } else { + runtime.log("Sibling gateways remain; kept shared helper and forward services."); + } + if (!scopedToSelectedGateway) { + stopHostGatewayProcesses( + { + run: runtime.run, + kill: runtime.kill, + env: runtime.env, + log: runtime.log, + warn: runtime.warn, + commandExists: runtime.commandExists, + }, + { logNoProcesses: true }, + ); + } + stopOllamaAuthProxy(paths, runtime, !scopedToSelectedGateway); + stopOpenRouterRuntimeAdapter(paths, runtime, { + scanOrphans: !scopedToSelectedGateway, + }); + stopModelRouter(paths, runtime, !scopedToSelectedGateway); } else if (step.name === "OpenShell resources") { - removeOpenShellResources(options, runtime); + if (!removeOpenShellResources(options, runtime, scopedToSelectedGateway, sandboxNames)) { + return { ok: false }; + } + if (scopedToSelectedGateway) { + stopHostGatewayProcesses( + { + run: runtime.run, + kill: runtime.kill, + env: runtime.env, + log: runtime.log, + warn: runtime.warn, + commandExists: runtime.commandExists, + }, + { + gatewayBin: runtime.env.NEMOCLAW_OPENSHELL_GATEWAY_BIN, + logNoProcesses: true, + openShellGatewayName: options.gatewayName || resolveGatewayName(GATEWAY_PORT), + openShellGatewayPort: GATEWAY_PORT, + preserveRuntimeFilesOnNonMatching: true, + stateDir: paths.selectedGatewayLocalStateDir, + }, + ); + } } else if (step.name === "NemoClaw CLI") { - removeNemoclawCli(paths, runtime); + if (scopedToSelectedGateway) { + runtime.log("Sibling gateways remain; kept the shared NemoClaw CLI and shell shims."); + } else { + removeNemoclawCli(paths, runtime); + } } else if (step.name === "Docker resources") { if (dockerIsAvailable(runtime)) { - removeDockerContainers(runtime); - removeDockerImages(runtime); + removeDockerContainers( + runtime, + scopedToSelectedGateway + ? options.gatewayName || resolveGatewayName(GATEWAY_PORT) + : undefined, + ); + if (scopedToSelectedGateway) { + runtime.log("Sibling gateways remain; kept shared Docker images."); + } else { + removeDockerImages(runtime); + } for (const action of step.actions) if (action.kind === "delete-docker-volume") removeDockerVolume(action.name, runtime); } } else if (step.name === "Ollama models") { - removeOllamaModels(options, runtime); + if (scopedToSelectedGateway) { + runtime.log("Sibling gateways remain; kept host-shared Ollama models."); + } else { + removeOllamaModels(options, runtime); + } } else if (step.name === "State and binaries") { removeManagedSwap(paths, runtime); - for (const pattern of paths.runtimeTempGlobs) removeGlob(pattern, runtime); - if (options.keepOpenShell) runtime.log("Keeping OpenShell binaries as requested."); - else - for (const target of paths.openshellInstallPaths) - removeFileWithOptionalSudo(target, runtime); + if (!scopedToSelectedGateway) { + for (const pattern of paths.runtimeTempGlobs) removeGlob(pattern, runtime); + if (options.keepOpenShell) runtime.log("Keeping OpenShell binaries as requested."); + else + for (const target of paths.openshellInstallPaths) + removeFileWithOptionalSudo(target, runtime); + } else { + runtime.log("Sibling gateways remain; kept shared runtime files and OpenShell binaries."); + } + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + const selectedIsDefault = path.resolve(paths.nemoclawStateDir) === path.resolve(sharedRoot); if ( !removePathExcept( paths.nemoclawStateDir, - [...preserveUnderStateDir, GATEWAYS_SUBDIR, path.basename(paths.managedSwapMarkerPath)], + [ + ...preserveUnderStateDir, + ...(selectedIsDefault + ? [GATEWAYS_SUBDIR, path.basename(paths.managedSwapMarkerPath)] + : []), + ...(scopedToSelectedGateway && selectedIsDefault ? ["source"] : []), + ], runtime, ) ) ok = false; - removePath(paths.gatewayLocalStateDir, runtime); - removePath(paths.openshellConfigDir, runtime); - removePath(paths.nemoclawConfigDir, runtime); + if (scopedToSelectedGateway) { + removePath(paths.selectedGatewayLocalStateDir, runtime); + runtime.log("Sibling gateways remain; kept shared OpenShell and NemoClaw config."); + } else { + removePath(paths.gatewayLocalStateDir, runtime); + removePath(paths.openshellConfigDir, runtime); + removePath(paths.nemoclawConfigDir, runtime); + } } } return { ok }; @@ -1035,9 +1255,10 @@ export function buildRunPlan( tmpDir: env.TMPDIR, xdgBinHome: env.XDG_BIN_HOME, }); + const gatewayName = options.gatewayName || resolveGatewayName(GATEWAY_PORT); const plan = buildUninstallPlan(paths, { deleteModels: options.deleteModels, - gatewayName: options.gatewayName, + gatewayName, keepOpenShell: options.keepOpenShell, shim: classifyShimPath(paths.nemoclawShimPath, deps.fs), }); @@ -1049,11 +1270,40 @@ export function runUninstallPlan( deps: UninstallRunDeps = {}, ): UninstallRunOutcome { const runtime = buildRuntime(deps); - const { paths, plan } = buildRunPlan(options, { ...deps, env: runtime.env }); - printBanner(runtime); - if (!confirm(options, runtime, paths)) return { exitCode: 0, plan }; - const preserveUnderStateDir = resolvePreserveSet(paths, options, runtime); - const { ok } = executePlan(plan, paths, options, runtime, preserveUnderStateDir); + const expectedGatewayName = resolveGatewayName(GATEWAY_PORT); + if (options.gatewayName && options.gatewayName !== expectedGatewayName) { + runtime.error( + `Refusing to uninstall gateway '${options.gatewayName}': NEMOCLAW_GATEWAY_PORT=${String(GATEWAY_PORT)} selects '${expectedGatewayName}'.`, + ); + const { plan } = buildRunPlan(options, { ...deps, env: runtime.env }); + return { exitCode: 1, plan }; + } + const resolvedOptions = { ...options, gatewayName: expectedGatewayName }; + const { paths, plan } = buildRunPlan(resolvedOptions, { ...deps, env: runtime.env }); + const scopedToSelectedGateway = otherGatewayEnvironmentsRemain(paths, runtime); + let sandboxNames: string[] = []; + if (scopedToSelectedGateway) { + try { + sandboxNames = selectedRegistrySandboxNames(paths, runtime); + } catch (error) { + runtime.error(error instanceof Error ? error.message : String(error)); + return { exitCode: 1, plan }; + } + } + printBanner(runtime, scopedToSelectedGateway, expectedGatewayName); + if (!confirm(resolvedOptions, runtime, paths, scopedToSelectedGateway)) { + return { exitCode: 0, plan }; + } + const preserveUnderStateDir = resolvePreserveSet(paths, resolvedOptions, runtime); + const { ok } = executePlan( + plan, + paths, + resolvedOptions, + runtime, + preserveUnderStateDir, + scopedToSelectedGateway, + sandboxNames, + ); if (ok) { printBye(runtime); } else { diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 0c4d3840d5b..66d0c18fcf2 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -21,6 +21,7 @@ const { sandboxActionTokens, } = require("./command-registry"); +import { migrateLegacyPortState } from "../state/legacy-port-migration"; import { type NormalizedArgv, type NormalizedGlobalArgv, @@ -456,6 +457,31 @@ function printUnknownSandboxOrCommand(cmd: string): never { /** Normalize public argv and route it to oclif or sandbox-first command handlers. */ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promise { + const stateFreeInvocation = + argv.length === 0 || + argv.includes("--help") || + argv.includes("-h") || + argv.includes("--version") || + argv[0] === "version" || + argv[0] === "help" || + argv[0] === "completion"; + if (!stateFreeInvocation) { + try { + const migration = migrateLegacyPortState(); + if (migration.migratedSandboxNames.length > 0 || migration.migratedSession) { + console.error( + ` Migrated legacy state for gateway port ${process.env.NEMOCLAW_GATEWAY_PORT}: ` + + `${String(migration.migratedSandboxNames.length)} sandbox(s).`, + ); + } + for (const warning of migration.warnings) console.error(` Warning: ${warning}`); + } catch (error) { + console.error(` ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + } + if (argv[0] && NATIVE_OCLIF_NAMESPACES.has(argv[0])) { await runNativeOclifArgv(argv); return; diff --git a/src/lib/domain/uninstall/paths.ts b/src/lib/domain/uninstall/paths.ts index 4155910dada..37ef50e095f 100644 --- a/src/lib/domain/uninstall/paths.ts +++ b/src/lib/domain/uninstall/paths.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { GATEWAY_PORT } from "../../core/ports"; +import { resolveGatewayStateDirName } from "../../onboard/gateway-binding"; import { nemoclawStateRoot } from "../../state/state-root"; export const DEFAULT_GATEWAY_NAME = "nemoclaw"; @@ -41,6 +42,7 @@ export interface UninstallPaths { agentAliasShimPaths: Array<{ binName: string; path: string }>; nemoclawStateDir: string; gatewayLocalStateDir: string; + selectedGatewayLocalStateDir: string; openshellConfigDir: string; openshellInstallPaths: string[]; repoRoot: string; @@ -62,6 +64,7 @@ function openshellInstallPathsForBinDirs(binDirs: string[]): string[] { export function defaultUninstallPaths(options: UninstallPathOptions): UninstallPaths { const xdgBinHome = options.xdgBinHome || path.join(options.home, ".local", "bin"); const tmpDir = options.tmpDir || "/tmp"; + const gatewayLocalStateDir = path.join(options.home, ".local", "state", "nemoclaw"); return { helperServiceGlob: path.join(tmpDir, "nemoclaw-services-*"), managedSwapMarkerPath: path.join(options.home, ".nemoclaw", "managed_swap"), @@ -72,7 +75,11 @@ export function defaultUninstallPaths(options: UninstallPathOptions): UninstallP path: path.join(options.home, ".local", "bin", binName), })), nemoclawStateDir: nemoclawStateRoot(options.home, GATEWAY_PORT), - gatewayLocalStateDir: path.join(options.home, ".local", "state", "nemoclaw"), + gatewayLocalStateDir, + selectedGatewayLocalStateDir: path.join( + gatewayLocalStateDir, + resolveGatewayStateDirName(GATEWAY_PORT), + ), openshellConfigDir: path.join(options.home, ".config", "openshell"), openshellInstallPaths: openshellInstallPathsForBinDirs(["/usr/local/bin", xdgBinHome]), repoRoot: options.repoRoot || path.resolve(__dirname, "..", "..", "..", ".."), diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index fbbfaf6ed36..5013f43aa02 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import { findAvailableDashboardPort, @@ -282,6 +285,54 @@ describe("findAvailableDashboardPort multi-gateway registry occupancy", () => { }); describe("getRegistryOccupiedDashboardPorts", () => { + it("aggregates a sibling gateway registry when host bind probes report the port free", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dashboard-host-index-")); + try { + vi.stubEnv("HOME", home); + const defaultRoot = path.join(home, ".nemoclaw"); + const siblingRoot = path.join(defaultRoot, "gateways", "9123"); + fs.mkdirSync(siblingRoot, { recursive: true }); + fs.writeFileSync( + path.join(defaultRoot, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "instance-b", + sandboxes: { + "instance-b": { + name: "instance-b", + gatewayName: "nemoclaw", + gatewayPort: 8080, + dashboardPort: 18790, + }, + }, + }), + ); + fs.writeFileSync( + path.join(siblingRoot, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "instance-a", + sandboxes: { + "instance-a": { + name: "instance-a", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + dashboardPort: 18789, + }, + }, + }), + ); + + const occupied = getRegistryOccupiedDashboardPorts("instance-b"); + assert.equal(occupied.get("18789"), "instance-a (gateway 9123)"); + assert.equal( + findAvailableDashboardPort("instance-b", 18789, "", () => false, occupied), + 18790, + ); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("returns a port→sandbox map for every sibling sandbox with a persisted dashboard port", () => { const occupied = getRegistryOccupiedDashboardPorts("current", () => ({ sandboxes: [ diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 1e420cbfd71..2a75761b758 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -14,12 +14,15 @@ */ import { spawnSync } from "node:child_process"; +import os from "node:os"; import { DASHBOARD_PORT, DASHBOARD_PORT_RANGE_END, DASHBOARD_PORT_RANGE_START, + GATEWAY_PORT, } from "../core/ports"; +import { listHostGatewayRegistryEntries } from "../state/gateway-registry"; // runner.ts is still CommonJS — use require so module shape matches. const { runCapture } = require("../runner"); @@ -28,6 +31,7 @@ type RunCaptureFn = typeof import("../runner").runCapture; type SandboxRegistryEntry = { name: string; dashboardPort?: number | null; + scopeGatewayPort?: number; }; export type ListSandboxesFn = () => { sandboxes: SandboxRegistryEntry[] }; @@ -166,13 +170,11 @@ export function findDashboardForwardOwner( * The host-level bind probe also misses Docker-mediated forwards on macOS, * which is exactly the scenario reported on multi-instance hosts. * - * The registry persists `dashboardPort` per sandbox and lives at host scope - * (one file under `~/.nemoclaw/sandboxes.json`), so consulting it during - * allocation closes the gap between gateway namespaces without enumerating - * forwards across every NemoClaw gateway. The forward-list value still wins - * for sandboxes whose forward exists on the currently selected gateway — - * the registry view is a supplementary signal for sandboxes whose owning - * gateway is not currently selected. + * The registry persists `dashboardPort` per sandbox. Allocation aggregates + * the default registry plus bounded real directories under `gateways/*`, so + * the view remains host-wide after gateway state becomes port-scoped. The + * forward-list value still wins for sandboxes whose forward exists on the + * currently selected gateway. */ function mergeOccupiedPorts( forwardOccupied: Map, @@ -189,33 +191,47 @@ function mergeOccupiedPorts( /** * Build a cross-gateway occupancy map (port → owning sandbox name) from the - * persisted sandbox registry, excluding the sandbox currently being allocated - * for. The registry is the single host-scope view of dashboard ports across - * every NemoClaw gateway — `openshell forward list` only knows about the + * persisted sandbox registries, excluding the current sandbox only in the + * selected gateway scope. `openshell forward list` only knows about the * currently selected gateway's forwards, so a fresh onboard against a second - * `NEMOCLAW_GATEWAY_PORT` gateway cannot see the first gateway's allocations - * without this view. + * `NEMOCLAW_GATEWAY_PORT` gateway needs this host-wide view. * - * `listSandboxes()` already degrades to an empty registry when - * `~/.nemoclaw/sandboxes.json` is missing or unparseable, so this helper does - * not need an extra catch-all. Any remaining error (e.g. an unreadable - * registry file with the wrong filesystem permissions) propagates so the - * allocator surfaces it instead of silently handing out a colliding port. + * Production enumeration is bounded and rejects symlinked, malformed, or + * unreadable registry state. Errors propagate so the allocator fails closed + * instead of silently handing out a colliding port. * * `listSandboxesFn` is an injectable seam for tests; production callers - * leave it at the default that reads `~/.nemoclaw/sandboxes.json`. + * leave it at the host-wide default. */ export function getRegistryOccupiedDashboardPorts( currentSandboxName: string, listSandboxesFn?: ListSandboxesFn, ): Map { const occupied = new Map(); - const list = listSandboxesFn ?? (require("../state/registry").listSandboxes as ListSandboxesFn); + const list = + listSandboxesFn ?? + (() => ({ + sandboxes: listHostGatewayRegistryEntries(process.env.HOME || os.homedir()).map( + ({ entry, gatewayPort }) => ({ + name: entry.name, + dashboardPort: entry.dashboardPort, + scopeGatewayPort: gatewayPort, + }), + ), + })); for (const entry of list().sandboxes) { - if (entry.name === currentSandboxName) continue; + if ( + entry.name === currentSandboxName && + (entry.scopeGatewayPort === undefined || entry.scopeGatewayPort === GATEWAY_PORT) + ) + continue; const port = entry.dashboardPort; if (typeof port !== "number" || !Number.isInteger(port) || port <= 0) continue; - occupied.set(String(port), entry.name); + const owner = + entry.scopeGatewayPort !== undefined && entry.scopeGatewayPort !== GATEWAY_PORT + ? `${entry.name} (gateway ${String(entry.scopeGatewayPort)})` + : entry.name; + occupied.set(String(port), owner); } return occupied; } diff --git a/src/lib/state/gateway-registry.test.ts b/src/lib/state/gateway-registry.test.ts new file mode 100644 index 00000000000..5a0cc757280 --- /dev/null +++ b/src/lib/state/gateway-registry.test.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { listHostGatewayRegistryEntries } from "./gateway-registry"; + +describe("host gateway registry index", () => { + it.runIf(process.platform !== "win32")( + "rejects a symlinked numeric gateway root instead of omitting its allocations", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-home-")); + const target = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-target-")); + try { + const gateways = path.join(home, ".nemoclaw", "gateways"); + fs.mkdirSync(gateways, { recursive: true }); + fs.symlinkSync(target, path.join(gateways, "9123"), "dir"); + + expect(() => listHostGatewayRegistryEntries(home)).toThrow(/not a real directory/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(target, { recursive: true, force: true }); + } + }, + ); + + it("rejects malformed sibling registries so allocation fails closed", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-malformed-")); + try { + const root = path.join(home, ".nemoclaw", "gateways", "9123"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(path.join(root, "sandboxes.json"), "[]"); + + expect(() => listHostGatewayRegistryEntries(home)).toThrow( + /does not contain a sandbox registry/, + ); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("rejects malformed persisted dashboard ports instead of treating them as free", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-port-")); + try { + const root = path.join(home, ".nemoclaw", "gateways", "9123"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "instance-a", + sandboxes: { + "instance-a": { + name: "instance-a", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + dashboardPort: "18789", + }, + }, + }), + ); + + expect(() => listHostGatewayRegistryEntries(home)).toThrow(/invalid dashboardPort/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("rejects sandbox names that could escape a gateway-owned snapshot directory", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-index-name-")); + try { + const root = path.join(home, ".nemoclaw", "gateways", "9123"); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "../outside", + sandboxes: { + "../outside": { + name: "../outside", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + }, + }, + }), + ); + + expect(() => listHostGatewayRegistryEntries(home)).toThrow(/invalid sandbox row/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/state/gateway-registry.ts b/src/lib/state/gateway-registry.ts new file mode 100644 index 00000000000..30381520ff6 --- /dev/null +++ b/src/lib/state/gateway-registry.ts @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { isErrnoException } from "../core/errno"; +import { isObjectRecord } from "../core/json-types"; +import { DEFAULT_GATEWAY_PORT } from "../core/ports"; +import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; +import { resolveGatewayName, resolveGatewayPortFromName } from "../onboard/gateway-binding"; +import { GATEWAYS_SUBDIR, nemoclawStateRoot } from "./state-root"; + +const MAX_REGISTRY_BYTES = 16 * 1024 * 1024; +const MAX_GATEWAY_ROOTS = 256; +const MAX_GATEWAY_DIRECTORY_ENTRIES = 1024; + +export interface GatewayRegistryEntry extends Record { + name: string; + dashboardPort?: number | null; + gatewayName?: string | null; + gatewayPort?: number | null; +} + +export interface GatewayRegistryDocument extends Record { + defaultSandbox: string | null; + sandboxes: Record; +} + +export interface GatewayStateRoot { + gatewayPort: number; + root: string; +} + +function stateError(message: string): Error { + return new Error(`Cannot safely inspect NemoClaw gateway state: ${message}`); +} + +export function assertGatewayStatePathSafe(home: string, target: string): void { + const resolvedHome = path.resolve(home); + const resolvedTarget = path.resolve(target); + const relative = path.relative(resolvedHome, resolvedTarget); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw stateError(`${resolvedTarget} is outside HOME`); + } + + let current = resolvedTarget; + while (current !== resolvedHome) { + try { + if (fs.lstatSync(current).isSymbolicLink()) { + throw stateError(`${current} is a symbolic link`); + } + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } + current = path.dirname(current); + } +} + +function openReadOnlyNoFollow(filePath: string): number { + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + if (noFollow === 0 && fs.lstatSync(filePath).isSymbolicLink()) { + throw stateError(`${filePath} is a symbolic link`); + } + return fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); +} + +function parseRegistry(filePath: string, raw: string): GatewayRegistryDocument { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw stateError(`${filePath} is not valid JSON`); + } + if (!isObjectRecord(parsed) || !isObjectRecord(parsed.sandboxes)) { + throw stateError(`${filePath} does not contain a sandbox registry`); + } + if ( + parsed.defaultSandbox !== undefined && + parsed.defaultSandbox !== null && + typeof parsed.defaultSandbox !== "string" + ) { + throw stateError(`${filePath} has an invalid defaultSandbox`); + } + + const sandboxes: Record = {}; + for (const [name, value] of Object.entries(parsed.sandboxes)) { + if ( + name.length > NAME_MAX_LENGTH || + !NAME_VALID_PATTERN.test(name) || + !isObjectRecord(value) || + value.name !== name + ) { + throw stateError(`${filePath} has an invalid sandbox row for ${JSON.stringify(name)}`); + } + if ( + value.dashboardPort !== undefined && + value.dashboardPort !== null && + (typeof value.dashboardPort !== "number" || + !Number.isInteger(value.dashboardPort) || + value.dashboardPort < 1 || + value.dashboardPort > 65535) + ) { + throw stateError( + `${filePath} has an invalid dashboardPort for sandbox ${JSON.stringify(name)}`, + ); + } + sandboxes[name] = value as GatewayRegistryEntry; + } + return { + ...parsed, + defaultSandbox: typeof parsed.defaultSandbox === "string" ? parsed.defaultSandbox : null, + sandboxes, + }; +} + +/** Read and strictly validate a registry without following user-controlled symlinks. */ +export function readGatewayRegistryFile( + home: string, + filePath: string, +): GatewayRegistryDocument | null { + assertGatewayStatePathSafe(home, path.dirname(filePath)); + let fd: number; + try { + fd = openReadOnlyNoFollow(filePath); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) throw stateError(`${filePath} is not a regular file`); + if (stat.size > MAX_REGISTRY_BYTES) { + throw stateError(`${filePath} exceeds the ${String(MAX_REGISTRY_BYTES)} byte limit`); + } + return parseRegistry(filePath, fs.readFileSync(fd, "utf8")); + } finally { + fs.closeSync(fd); + } +} + +/** Resolve the gateway identity recorded by a registry row, rejecting ambiguity. */ +export function registryEntryGatewayPort(entry: GatewayRegistryEntry): number { + const hasPort = entry.gatewayPort !== undefined && entry.gatewayPort !== null; + const hasName = entry.gatewayName !== undefined && entry.gatewayName !== null; + const port = entry.gatewayPort; + const name = entry.gatewayName; + + if ( + hasPort && + (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) + ) { + throw stateError(`sandbox ${JSON.stringify(entry.name)} has an invalid gatewayPort`); + } + if (hasName && typeof name !== "string") { + throw stateError(`sandbox ${JSON.stringify(entry.name)} has an invalid gatewayName`); + } + + const portFromName = typeof name === "string" ? resolveGatewayPortFromName(name) : null; + if (hasName && portFromName === null) { + throw stateError(`sandbox ${JSON.stringify(entry.name)} has an unrecognized gatewayName`); + } + if (typeof port === "number") { + if (typeof name === "string" && resolveGatewayName(port) !== name) { + throw stateError(`sandbox ${JSON.stringify(entry.name)} has conflicting gateway identity`); + } + return port; + } + if (portFromName !== null) return portFromName; + return DEFAULT_GATEWAY_PORT; +} + +/** Enumerate the default root plus bounded, real, numeric non-default gateway roots. */ +export function listGatewayStateRoots(home: string): GatewayStateRoot[] { + const sharedRoot = nemoclawStateRoot(home, DEFAULT_GATEWAY_PORT); + const gatewaysDir = path.join(sharedRoot, GATEWAYS_SUBDIR); + assertGatewayStatePathSafe(home, gatewaysDir); + + let directory: fs.Dir; + try { + directory = fs.opendirSync(gatewaysDir); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + return [{ gatewayPort: DEFAULT_GATEWAY_PORT, root: sharedRoot }]; + } + throw error; + } + + const roots: GatewayStateRoot[] = []; + let inspected = 0; + try { + let entry: fs.Dirent | null; + while ((entry = directory.readSync()) !== null) { + inspected += 1; + if (inspected > MAX_GATEWAY_DIRECTORY_ENTRIES) { + throw stateError( + `more than ${String(MAX_GATEWAY_DIRECTORY_ENTRIES)} entries are present under ${gatewaysDir}`, + ); + } + if (!/^\d{1,5}$/.test(entry.name)) continue; + const gatewayPort = Number(entry.name); + if (gatewayPort < 1 || gatewayPort > 65535 || gatewayPort === DEFAULT_GATEWAY_PORT) continue; + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw stateError(`${path.join(gatewaysDir, entry.name)} is not a real directory`); + } + roots.push({ gatewayPort, root: path.join(gatewaysDir, entry.name) }); + if (roots.length > MAX_GATEWAY_ROOTS) { + throw stateError(`more than ${String(MAX_GATEWAY_ROOTS)} gateway roots are present`); + } + } + } finally { + directory.closeSync(); + } + roots.sort((a, b) => a.gatewayPort - b.gatewayPort); + return [{ gatewayPort: DEFAULT_GATEWAY_PORT, root: sharedRoot }, ...roots]; +} + +export interface HostGatewayRegistryEntry { + entry: GatewayRegistryEntry; + gatewayPort: number; + registryFile: string; + stateRoot: string; +} + +/** Aggregate every valid host registry, retaining each row's canonical gateway identity. */ +export function listHostGatewayRegistryEntries(home: string): HostGatewayRegistryEntry[] { + const result: HostGatewayRegistryEntry[] = []; + for (const state of listGatewayStateRoots(home)) { + const registryFile = path.join(state.root, "sandboxes.json"); + const registry = readGatewayRegistryFile(home, registryFile); + if (!registry) continue; + for (const entry of Object.values(registry.sandboxes)) { + const gatewayPort = registryEntryGatewayPort(entry); + if (state.gatewayPort !== DEFAULT_GATEWAY_PORT && gatewayPort !== state.gatewayPort) { + throw stateError( + `${registryFile} contains sandbox ${JSON.stringify(entry.name)} for gateway port ${String(gatewayPort)}`, + ); + } + result.push({ entry, gatewayPort, registryFile, stateRoot: state.root }); + } + } + return result; +} diff --git a/src/lib/state/legacy-port-migration.test.ts b/src/lib/state/legacy-port-migration.test.ts new file mode 100644 index 00000000000..bfdcf20091e --- /dev/null +++ b/src/lib/state/legacy-port-migration.test.ts @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { migrateLegacyPortState } from "./legacy-port-migration"; + +const homes: string[] = []; + +function makeHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-legacy-port-state-")); + homes.push(home); + return home; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value)); +} + +function readJson(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; +} + +afterEach(() => { + for (const home of homes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("legacy non-default gateway state migration", () => { + it("partitions a selected registry and moves identity-bound session, credentials, and snapshots", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + writeJson(path.join(shared, "sandboxes.json"), { + defaultSandbox: "default-box", + extraProviders: ["custom-provider"], + sandboxes: { + "default-box": { + name: "default-box", + gatewayName: "nemoclaw", + gatewayPort: 8080, + dashboardPort: 18789, + }, + "port-box": { + name: "port-box", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + dashboardPort: 18790, + }, + }, + }); + writeJson(path.join(shared, "onboard-session.json"), { + sandboxName: "port-box", + status: "in_progress", + metadata: { gatewayName: "nemoclaw-9123" }, + }); + writeJson(path.join(shared, "credentials.json"), { NVIDIA_API_KEY: "legacy-secret" }); + writeJson(path.join(shared, "usage-notice.json"), { acceptedVersion: "1" }); + writeJson(path.join(shared, "state", "default-forward.json"), { pid: 123 }); + writeJson(path.join(shared, "rebuild-backups", "default-box", "one", "manifest.json"), {}); + writeJson(path.join(shared, "rebuild-backups", "port-box", "two", "manifest.json"), {}); + + const result = migrateLegacyPortState({ home, gatewayPort: 9123 }); + + expect(result).toEqual({ + migratedSandboxNames: ["port-box"], + migratedSession: true, + warnings: [expect.stringContaining("Left ambiguous legacy state")], + }); + expect(Object.keys(readJson(path.join(shared, "sandboxes.json")).sandboxes as object)).toEqual([ + "default-box", + ]); + expect( + Object.keys(readJson(path.join(selected, "sandboxes.json")).sandboxes as object), + ).toEqual(["port-box"]); + expect(fs.existsSync(path.join(shared, "onboard-session.json"))).toBe(false); + expect(fs.existsSync(path.join(selected, "onboard-session.json"))).toBe(true); + expect(fs.existsSync(path.join(shared, "credentials.json"))).toBe(false); + expect(fs.existsSync(path.join(selected, "credentials.json"))).toBe(true); + expect(fs.existsSync(path.join(shared, "usage-notice.json"))).toBe(true); + expect(fs.existsSync(path.join(selected, "usage-notice.json"))).toBe(false); + expect(fs.existsSync(path.join(shared, "state", "default-forward.json"))).toBe(true); + expect(fs.existsSync(path.join(selected, "state"))).toBe(false); + expect( + fs.existsSync(path.join(selected, "rebuild-backups", "port-box", "two", "manifest.json")), + ).toBe(true); + expect( + fs.existsSync(path.join(shared, "rebuild-backups", "default-box", "one", "manifest.json")), + ).toBe(true); + + expect(migrateLegacyPortState({ home, gatewayPort: 9123 })).toEqual({ + migratedSandboxNames: [], + migratedSession: false, + warnings: [], + }); + }); + + it("refuses a row whose persisted gateway name and port conflict without mutating state", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + writeJson(path.join(shared, "sandboxes.json"), { + defaultSandbox: "ambiguous", + sandboxes: { + ambiguous: { + name: "ambiguous", + gatewayName: "nemoclaw-9124", + gatewayPort: 9123, + }, + }, + }); + const before = fs.readFileSync(path.join(shared, "sandboxes.json"), "utf8"); + + expect(() => migrateLegacyPortState({ home, gatewayPort: 9123 })).toThrow( + /conflicting gateway identity/, + ); + expect(fs.readFileSync(path.join(shared, "sandboxes.json"), "utf8")).toBe(before); + expect(fs.existsSync(path.join(shared, "gateways", "9123", "sandboxes.json"))).toBe(false); + }); + + it("partitions provable rows but leaves credentials whose gateway ownership is ambiguous", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + writeJson(path.join(shared, "sandboxes.json"), { + defaultSandbox: "default-box", + sandboxes: { + "default-box": { name: "default-box" }, + "port-box": { name: "port-box", gatewayName: "nemoclaw-9123", gatewayPort: 9123 }, + }, + }); + writeJson(path.join(shared, "credentials.json"), { NVIDIA_API_KEY: "ambiguous-secret" }); + + const result = migrateLegacyPortState({ home, gatewayPort: 9123 }); + + expect(result.migratedSandboxNames).toEqual(["port-box"]); + expect(result.warnings.join("\n")).toContain("Left ambiguous"); + expect(fs.existsSync(path.join(shared, "credentials.json"))).toBe(true); + expect(fs.existsSync(path.join(shared, "gateways", "9123", "credentials.json"))).toBe(false); + }); + + it("moves singleton state when every legacy registry row belongs to the selected gateway", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + writeJson(path.join(shared, "sandboxes.json"), { + defaultSandbox: "port-box", + sandboxes: { + "port-box": { name: "port-box", gatewayName: "nemoclaw-9123", gatewayPort: 9123 }, + }, + }); + writeJson(path.join(shared, "credentials.json"), { NVIDIA_API_KEY: "selected-secret" }); + + const result = migrateLegacyPortState({ home, gatewayPort: 9123 }); + + expect(result.warnings).toEqual([]); + expect(fs.existsSync(path.join(shared, "credentials.json"))).toBe(false); + expect(fs.existsSync(path.join(selected, "credentials.json"))).toBe(true); + }); + + it("does not modify the byte-compatible default gateway root", () => { + const home = makeHome(); + const registry = path.join(home, ".nemoclaw", "sandboxes.json"); + writeJson(registry, { + defaultSandbox: "default-box", + sandboxes: { "default-box": { name: "default-box" } }, + }); + const before = fs.readFileSync(registry, "utf8"); + + expect(migrateLegacyPortState({ home, gatewayPort: 8080 })).toEqual({ + migratedSandboxNames: [], + migratedSession: false, + warnings: [], + }); + expect(fs.readFileSync(registry, "utf8")).toBe(before); + }); +}); diff --git a/src/lib/state/legacy-port-migration.ts b/src/lib/state/legacy-port-migration.ts new file mode 100644 index 00000000000..89772e9e083 --- /dev/null +++ b/src/lib/state/legacy-port-migration.ts @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { isErrnoException } from "../core/errno"; +import { isObjectRecord } from "../core/json-types"; +import { DEFAULT_GATEWAY_PORT, GATEWAY_PORT } from "../core/ports"; +import { resolveGatewayPortFromName } from "../onboard/gateway-binding"; +import { + assertGatewayStatePathSafe, + type GatewayRegistryDocument, + type GatewayRegistryEntry, + readGatewayRegistryFile, + registryEntryGatewayPort, +} from "./gateway-registry"; +import { nemoclawStateRoot, resolveHome } from "./state-root"; + +const MIGRATION_LOCK = ".gateway-state-migration.lock"; +const MAX_MIGRATABLE_JSON_BYTES = 16 * 1024 * 1024; +const LEGACY_BUNDLE_ENTRIES = [ + "backups", + "blueprints", + "credentials.json", + "model-router-venv", + "mounts", + "ollama-auth-proxy.pid", + "ollama-proxy-token", + "onboard-failures", + "openrouter-runtime-adapter.pid", + "state", + "usage-notice.json", +] as const; +const SESSION_BOUND_ENTRIES = ["credentials.json"] as const; + +export interface LegacyPortMigrationResult { + migratedSandboxNames: string[]; + migratedSession: boolean; + warnings: string[]; +} + +function migrationError(message: string): Error { + return new Error(`Cannot safely migrate legacy NemoClaw state for this gateway port: ${message}`); +} + +function ensureRealDirectory(home: string, dir: string): void { + assertGatewayStatePathSafe(home, dir); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + assertGatewayStatePathSafe(home, dir); + const stat = fs.lstatSync(dir); + if (!stat.isDirectory()) throw migrationError(`${dir} is not a directory`); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(dir, 0o700); +} + +function writeJsonAtomic(home: string, filePath: string, value: unknown): void { + const dir = path.dirname(filePath); + ensureRealDirectory(home, dir); + try { + if (fs.lstatSync(filePath).isSymbolicLink()) { + throw migrationError(`${filePath} is a symbolic link`); + } + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } + const temp = `${filePath}.migration.${String(process.pid)}.${String(Date.now())}`; + let fd: number | null = null; + try { + fd = fs.openSync( + temp, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + fs.writeFileSync(fd, JSON.stringify(value, null, 2)); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = null; + fs.renameSync(temp, filePath); + } finally { + if (fd !== null) fs.closeSync(fd); + try { + fs.rmSync(temp, { force: true }); + } catch { + // Best effort after an interrupted atomic write. + } + } +} + +function readJsonNoFollow(home: string, filePath: string): unknown | null { + assertGatewayStatePathSafe(home, path.dirname(filePath)); + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + let fd: number; + try { + if (noFollow === 0 && fs.lstatSync(filePath).isSymbolicLink()) { + throw migrationError(`${filePath} is a symbolic link`); + } + fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) throw migrationError(`${filePath} is not a regular file`); + if (stat.size > MAX_MIGRATABLE_JSON_BYTES) { + throw migrationError( + `${filePath} exceeds the ${String(MAX_MIGRATABLE_JSON_BYTES)} byte migration limit`, + ); + } + return JSON.parse(fs.readFileSync(fd, "utf8")); + } catch (error) { + if (error instanceof SyntaxError) throw migrationError(`${filePath} is not valid JSON`); + throw error; + } finally { + fs.closeSync(fd); + } +} + +function firstSandboxName(sandboxes: Record): string | null { + return Object.keys(sandboxes).sort()[0] ?? null; +} + +function registryWithSandboxes( + source: GatewayRegistryDocument | null, + sandboxes: Record, + preferredDefault: string | null, +): GatewayRegistryDocument { + const defaultSandbox = + preferredDefault && Object.hasOwn(sandboxes, preferredDefault) + ? preferredDefault + : firstSandboxName(sandboxes); + return { + ...(source ?? {}), + defaultSandbox, + sandboxes, + }; +} + +function mergeSelectedRegistry( + legacy: GatewayRegistryDocument | null, + selectedEntries: Record, + existing: GatewayRegistryDocument | null, + gatewayPort: number, +): GatewayRegistryDocument { + const merged = { ...(existing?.sandboxes ?? {}) }; + for (const [name, entry] of Object.entries(selectedEntries)) { + const existingEntry = merged[name]; + if (existingEntry && JSON.stringify(existingEntry) !== JSON.stringify(entry)) { + throw migrationError(`sandbox ${JSON.stringify(name)} differs between legacy and port state`); + } + merged[name] = entry; + } + for (const entry of Object.values(merged)) { + if (registryEntryGatewayPort(entry) !== gatewayPort) { + throw migrationError( + `${path.join(nemoclawStateRoot("~", gatewayPort), "sandboxes.json")} contains a sandbox for another gateway`, + ); + } + } + const preferred = + existing?.defaultSandbox && Object.hasOwn(merged, existing.defaultSandbox) + ? existing.defaultSandbox + : (legacy?.defaultSandbox ?? null); + return registryWithSandboxes(existing ?? legacy, merged, preferred); +} + +function sessionGatewayPort( + session: unknown, + registryPortsByName: ReadonlyMap, +): number | null { + if (!isObjectRecord(session)) throw migrationError("onboard-session.json is not an object"); + const metadata = session.metadata; + const gatewayName = isObjectRecord(metadata) ? metadata.gatewayName : undefined; + const sandboxName = typeof session.sandboxName === "string" ? session.sandboxName : null; + const rowPort = sandboxName ? (registryPortsByName.get(sandboxName) ?? null) : null; + + if (gatewayName !== undefined && typeof gatewayName !== "string") { + throw migrationError("onboard-session.json has an invalid gatewayName"); + } + const metadataPort = + typeof gatewayName === "string" ? resolveGatewayPortFromName(gatewayName) : null; + if (typeof gatewayName === "string" && metadataPort === null) { + throw migrationError("onboard-session.json has an unrecognized gatewayName"); + } + if (metadataPort !== null && rowPort !== null && metadataPort !== rowPort) { + throw migrationError("onboard-session.json conflicts with its sandbox registry row"); + } + return metadataPort ?? rowPort; +} + +function movePath(home: string, source: string, destination: string): boolean { + let sourceStat: fs.Stats; + try { + sourceStat = fs.lstatSync(source); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return false; + throw error; + } + if (sourceStat.isSymbolicLink()) throw migrationError(`${source} is a symbolic link`); + assertGatewayStatePathSafe(home, source); + assertGatewayStatePathSafe(home, destination); + if (fs.existsSync(destination)) { + throw migrationError(`${destination} already exists; refusing to overwrite it`); + } + ensureRealDirectory(home, path.dirname(destination)); + fs.renameSync(source, destination); + return true; +} + +function migrateSandboxBackups( + home: string, + sharedRoot: string, + selectedRoot: string, + sandboxNames: readonly string[], +): void { + for (const sandboxName of sandboxNames) { + movePath( + home, + path.join(sharedRoot, "rebuild-backups", sandboxName), + path.join(selectedRoot, "rebuild-backups", sandboxName), + ); + } +} + +function acquireDirectoryLock(home: string, lock: string): string { + ensureRealDirectory(home, path.dirname(lock)); + try { + fs.mkdirSync(lock, { mode: 0o700 }); + fs.writeFileSync(path.join(lock, "owner"), String(process.pid), { mode: 0o600 }); + return lock; + } catch (error) { + if (isErrnoException(error) && error.code === "EEXIST") { + throw migrationError(`another state operation owns ${lock}; retry after it completes`); + } + throw error; + } +} + +/** + * Partition pre-segregation state into the selected non-default gateway root. + * Registry rows move only when their persisted canonical gateway identity is + * unambiguous. Singleton state moves only when the session (or the entire + * legacy registry) proves that it belongs to the selected gateway. + */ +export function migrateLegacyPortState( + options: { gatewayPort?: number; home?: string } = {}, +): LegacyPortMigrationResult { + const gatewayPort = options.gatewayPort ?? GATEWAY_PORT; + const home = path.resolve(options.home || resolveHome()); + const result: LegacyPortMigrationResult = { + migratedSandboxNames: [], + migratedSession: false, + warnings: [], + }; + if (gatewayPort === DEFAULT_GATEWAY_PORT) return result; + + const sharedRoot = nemoclawStateRoot(home, DEFAULT_GATEWAY_PORT); + const selectedRoot = nemoclawStateRoot(home, gatewayPort); + const legacyRegistryFile = path.join(sharedRoot, "sandboxes.json"); + const selectedRegistryFile = path.join(selectedRoot, "sandboxes.json"); + const legacyRegistry = readGatewayRegistryFile(home, legacyRegistryFile); + const legacySessionFile = path.join(sharedRoot, "onboard-session.json"); + const legacySession = readJsonNoFollow(home, legacySessionFile); + if (!legacyRegistry && legacySession === null) return result; + + const lock = acquireDirectoryLock(home, path.join(sharedRoot, MIGRATION_LOCK)); + const registryLocks: string[] = []; + try { + registryLocks.push(acquireDirectoryLock(home, `${legacyRegistryFile}.lock`)); + // Re-read under the shared lock so classification and writes use one view. + const currentLegacy = readGatewayRegistryFile(home, legacyRegistryFile); + const registryPortsByName = new Map(); + const selectedEntries: Record = {}; + const remainingEntries: Record = {}; + for (const [name, entry] of Object.entries(currentLegacy?.sandboxes ?? {})) { + const rowPort = registryEntryGatewayPort(entry); + registryPortsByName.set(name, rowPort); + if (rowPort === gatewayPort) selectedEntries[name] = entry; + else remainingEntries[name] = entry; + } + + const session = readJsonNoFollow(home, legacySessionFile); + const recordedSessionPort = + session === null ? null : sessionGatewayPort(session, registryPortsByName); + const selectedNames = Object.keys(selectedEntries).sort(); + const sessionBelongsToSelected = recordedSessionPort === gatewayPort; + const wholeLegacyBundleBelongsToSelected = + selectedNames.length > 0 && + Object.keys(remainingEntries).length === 0 && + (session === null || sessionBelongsToSelected); + + if (selectedNames.length === 0 && !sessionBelongsToSelected) return result; + + registryLocks.push(acquireDirectoryLock(home, `${selectedRegistryFile}.lock`)); + const existingSelected = readGatewayRegistryFile(home, selectedRegistryFile); + const selectedRegistry = mergeSelectedRegistry( + currentLegacy, + selectedEntries, + existingSelected, + gatewayPort, + ); + writeJsonAtomic(home, selectedRegistryFile, selectedRegistry); + + migrateSandboxBackups(home, sharedRoot, selectedRoot, selectedNames); + + if (sessionBelongsToSelected) { + const activeLock = path.join(sharedRoot, "onboard.lock"); + if (fs.existsSync(activeLock)) { + throw migrationError( + `legacy onboarding lock ${activeLock} is present; finish or stop that run first`, + ); + } + result.migratedSession = movePath( + home, + legacySessionFile, + path.join(selectedRoot, "onboard-session.json"), + ); + } else if (session !== null && recordedSessionPort === null && selectedNames.length > 0) { + result.warnings.push( + `Left ambiguous ${legacySessionFile} in place because it has no recorded gateway identity.`, + ); + } + + const entriesToMove: readonly string[] = wholeLegacyBundleBelongsToSelected + ? LEGACY_BUNDLE_ENTRIES + : sessionBelongsToSelected + ? SESSION_BOUND_ENTRIES + : []; + for (const entry of entriesToMove) { + movePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); + } + + const movedEntries = new Set(entriesToMove); + const entriesLeftAmbiguous = LEGACY_BUNDLE_ENTRIES.filter( + (entry) => !movedEntries.has(entry) && fs.existsSync(path.join(sharedRoot, entry)), + ); + if (selectedNames.length > 0 && entriesLeftAmbiguous.length > 0) { + result.warnings.push( + `Left ambiguous legacy state under ${sharedRoot}: ${entriesLeftAmbiguous.join(", ")}. Review ownership before migrating or removing it; NemoClaw did not copy it into ${selectedRoot}.`, + ); + } + + if (currentLegacy && selectedNames.length > 0) { + const remainingRegistry = registryWithSandboxes( + currentLegacy, + remainingEntries, + currentLegacy.defaultSandbox, + ); + writeJsonAtomic(home, legacyRegistryFile, remainingRegistry); + } + result.migratedSandboxNames = selectedNames; + return result; + } finally { + for (const registryLock of registryLocks.reverse()) { + fs.rmSync(registryLock, { recursive: true, force: true }); + } + fs.rmSync(lock, { recursive: true, force: true }); + } +} diff --git a/test/e2e/live/concurrent-gateway-ports.test.ts b/test/e2e/live/concurrent-gateway-ports.test.ts index 0d72f5ecbf8..bc2db09f4ae 100644 --- a/test/e2e/live/concurrent-gateway-ports.test.ts +++ b/test/e2e/live/concurrent-gateway-ports.test.ts @@ -6,7 +6,7 @@ * Preserves the real-system boundaries: two NemoClaw onboards on one * host, per-port OpenShell Docker-driver gateways, dashboard forward * allocation, port-scoped `nemoclaw list`, OpenShell sandbox discovery, host - * socket probes, and destroy/health cleanup. + * socket probes, and selected-instance uninstall/health cleanup. */ import fs from "node:fs"; @@ -184,6 +184,20 @@ async function expectPortListening( return result; } +async function expectPortNotListening( + host: HostCliClient, + port: string, + artifactName: string, +): Promise { + const result = await host.command("bash", ["-lc", `! ss -ltn | grep -Eq '[:.]${port}\\b'`], { + artifactName, + env: commandEnv(), + timeoutMs: 30_000, + }); + expect(result.exitCode, resultText(result)).toBe(0); + return result; +} + async function prerequisiteOrSkip( host: HostCliClient, skip: (message: string) => never, @@ -326,7 +340,7 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and "sandbox B onboards with NEMOCLAW_GATEWAY_PORT on a non-default gateway", "both sandboxes, gateways, and dashboard forwards coexist without port collision", "each port-scoped registry lists only the sandbox owned by that gateway", - "destroying sandbox B leaves sandbox A healthy on the default gateway", + "uninstalling gateway B removes only its scoped state and leaves gateway A plus the shared CLI healthy", ], gatewayA, gatewayB, @@ -441,21 +455,41 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and expect(dashboardB, listGatewayB.stdout).toBeTruthy(); expect(dashboardB).not.toBe(dashboardA); - const destroyB = await command(host, [SANDBOX_B, "destroy", "--yes"], { - artifactName: "phase-4-destroy-sandbox-b", + const uninstallB = await command(host, ["uninstall", "--yes", "--destroy-user-data"], { + artifactName: "phase-4-uninstall-gateway-b", env: commandEnv({ NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT_B }), timeoutMs: 5 * 60_000, }); - expect(destroyB.exitCode, resultText(destroyB)).toBe(0); + expect(uninstallB.exitCode, resultText(uninstallB)).toBe(0); - const phaseAAfterDestroyB = await waitForSandboxReady( + const phaseAAfterUninstallB = await waitForSandboxReady( sandbox, SANDBOX_A, gatewayA, - "phase-4-sandbox-a-still-ready-after-b-destroy", + "phase-4-sandbox-a-still-ready-after-b-uninstall", ); - expect(["Ready", "Running"]).toContain(phaseAAfterDestroyB); + expect(["Ready", "Running"]).toContain(phaseAAfterUninstallB); await expectPortListening(host, GATEWAY_PORT_A, "phase-4-gateway-port-a-still-listening"); + await expectPortNotListening(host, GATEWAY_PORT_B, "phase-4-gateway-port-b-stopped"); + + const listAAfterUninstallB = await command(host, ["list"], { + artifactName: "phase-4-nemoclaw-list-a-after-b-uninstall", + env: commandEnv({ NEMOCLAW_GATEWAY_PORT: GATEWAY_PORT_A }), + timeoutMs: 60_000, + }); + expect(listAAfterUninstallB.exitCode, resultText(listAAfterUninstallB)).toBe(0); + expect(outputIncludesSandbox(listAAfterUninstallB.stdout, SANDBOX_A)).toBe(true); + + const scopedStateRemoved = await host.command( + "bash", + ["-lc", `test ! -e "$HOME/.nemoclaw/gateways/${GATEWAY_PORT_B}"`], + { + artifactName: "phase-4-gateway-b-state-removed", + env: commandEnv(), + timeoutMs: 30_000, + }, + ); + expect(scopedStateRemoved.exitCode, resultText(scopedStateRemoved)).toBe(0); await artifacts.target.complete({ id: "concurrent-gateway-ports", @@ -470,7 +504,9 @@ test("concurrent gateway ports: onboards two sandboxes on isolated gateways and outputIncludesSandbox(listGatewayB.stdout, SANDBOX_B) && !outputIncludesSandbox(listGatewayB.stdout, SANDBOX_A), dashboardPortsDistinct: Boolean(dashboardA && dashboardB && dashboardA !== dashboardB), - sandboxAPreservedAfterDestroyB: ["Ready", "Running"].includes(phaseAAfterDestroyB), + gatewayBUninstalled: uninstallB.exitCode === 0 && scopedStateRemoved.exitCode === 0, + sandboxAPreservedAfterUninstallB: ["Ready", "Running"].includes(phaseAAfterUninstallB), + sharedCliPreserved: listAAfterUninstallB.exitCode === 0, }, }); }); diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts new file mode 100644 index 00000000000..c07fb68673b --- /dev/null +++ b/test/install-gateway-state-root.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const INSTALLER = path.join(import.meta.dirname, "..", "scripts", "install.sh"); +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value)); +} + +function readJson(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; +} + +function runInstallerFunctions( + home: string, + body: string, +): { output: string; status: number | null } { + const result = spawnSync("bash", ["-c", `source "${INSTALLER}" >/dev/null\n${body}`], { + encoding: "utf8", + env: { + ...process.env, + BASH_ENV: "", + ENV: "", + HOME: home, + NEMOCLAW_GATEWAY_PORT: "9123", + }, + }); + return { output: `${result.stdout}${result.stderr}`, status: result.status }; +} + +describe("install.sh gateway-scoped recovery state", () => { + it("filters a pre-segregation shared registry to the selected gateway before backup", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-legacy-port-")); + try { + const registry = path.join(home, ".nemoclaw", "sandboxes.json"); + writeJson(registry, { + defaultSandbox: "default-box", + sandboxes: { + "default-box": { + name: "default-box", + gatewayName: "nemoclaw", + gatewayPort: 8080, + nemoclawVersion: "v1", + }, + "port-box": { + name: "port-box", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + }, + }, + }); + + const result = runInstallerFunctions( + home, + `printf 'state=%s\n' "$(nemoclaw_state_dir)" +printf 'count=%s\n' "$(registered_sandbox_count)" +printf 'ambiguous=%s\n' "$(legacy_ambiguous_sandbox_names_json '${registry}')"`, + ); + + expect(result.status, result.output).toBe(0); + expect(result.output).toContain(`state=${home}/.nemoclaw/gateways/9123`); + expect(result.output).toContain("count=1"); + expect(result.output).toContain('ambiguous=["port-box"]'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("uses only the selected port's session and registry for post-install recovery", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-selected-port-")); + try { + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + writeJson(path.join(shared, "onboard-session.json"), { + sandboxName: "wrong-default", + agent: "openclaw", + }); + writeJson(path.join(selected, "onboard-session.json"), { + sandboxName: "port-box", + agent: "hermes", + }); + writeJson(path.join(selected, "sandboxes.json"), { + defaultSandbox: "port-box", + sandboxes: { + "port-box": { + name: "port-box", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + }, + }, + }); + + const result = runInstallerFunctions( + home, + `printf 'sandbox=%s\n' "$(resolve_default_sandbox_name)" +printf 'agent=%s\n' "$(resolve_onboarded_agent)"`, + ); + + expect(result.status, result.output).toBe(0); + expect(result.output).toContain("sandbox=port-box"); + expect(result.output).toContain("agent=hermes"); + expect(result.output).not.toContain("wrong-default"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("runs the one-time partition before a normal selected-port CLI command", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-legacy-port-")); + try { + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + writeJson(path.join(shared, "sandboxes.json"), { + defaultSandbox: "port-box", + sandboxes: { + "port-box": { + name: "port-box", + gatewayName: "nemoclaw-9123", + gatewayPort: 9123, + }, + }, + }); + writeJson(path.join(shared, "onboard-session.json"), { + sandboxName: "port-box", + status: "complete", + metadata: { gatewayName: "nemoclaw-9123" }, + }); + + const result = spawnSync(process.execPath, [CLI, "credentials", "list"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home, + NEMOCLAW_GATEWAY_PORT: "9123", + }, + }); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stderr).toContain("Migrated legacy state for gateway port 9123"); + const selectedRegistry = readJson(path.join(selected, "sandboxes.json")); + const sharedRegistry = readJson(path.join(shared, "sandboxes.json")); + expect(Object.keys(selectedRegistry.sandboxes as object)).toEqual(["port-box"]); + expect(Object.keys(sharedRegistry.sandboxes as object)).toEqual([]); + expect(fs.existsSync(path.join(selected, "onboard-session.json"))).toBe(true); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); From 4b11dd062f9a347ee3b27c2656ae8a1c25d07f1a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 12:59:10 -0700 Subject: [PATCH 13/28] test(uninstall): model preservable state precisely Signed-off-by: Prekshi Vyas --- test/fixtures/uninstall-prompt-pty-driver.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/fixtures/uninstall-prompt-pty-driver.ts b/test/fixtures/uninstall-prompt-pty-driver.ts index d94c3dddc83..25bf93c16eb 100644 --- a/test/fixtures/uninstall-prompt-pty-driver.ts +++ b/test/fixtures/uninstall-prompt-pty-driver.ts @@ -44,6 +44,9 @@ if (process.env.PTY_DRIVER_POISON_STDIN === "1") { const preservable = process.env.PTY_DRIVER_PRESERVABLE === "1"; const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-pty-driver-")); +if (preservable) { + fs.mkdirSync(path.join(home, ".nemoclaw", "backups"), { recursive: true }); +} const okResult: RunResult = { status: 0, stdout: "", stderr: "" }; const { exitCode } = runUninstallPlan( @@ -62,7 +65,7 @@ const { exitCode } = runUninstallPlan( NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "", TMPDIR: home, } as NodeJS.ProcessEnv, - existsSync: (target) => preservable && target.includes(".nemoclaw"), + existsSync: fs.existsSync, kill: () => true, rmSync: (() => {}) as never, run: () => okResult, From 31a3b56e15c58c2e7757ba94a8c6a2ce2d5f4ed4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 13:07:56 -0700 Subject: [PATCH 14/28] fix(uninstall): preserve legacy sibling gateways Signed-off-by: Prekshi Vyas --- .../run-plan-gateway-segregation.test.ts | 165 ++++++++++++++ src/lib/actions/uninstall/run-plan.ts | 210 +++++++++++++++--- 2 files changed, 346 insertions(+), 29 deletions(-) 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 41512d83fd9..a05193403e7 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -7,6 +7,8 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { readGatewayRegistryFile } from "../../state/gateway-registry"; +import { migrateLegacyPortState } from "../../state/legacy-port-migration"; import { type RunResult, runUninstallPlan } from "./run-plan"; function ok(stdout = ""): RunResult { @@ -262,6 +264,169 @@ describe("uninstall gateway-port segregation (#3053)", () => { } }); + it("keeps a legacy non-default sibling after migrating and uninstalling the selected port", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-legacy-sibling-")); + const selectedPort = 9123; + const siblingPort = 9124; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", String(selectedPort)); + vi.resetModules(); + const { runUninstallPlan: runPortUninstall } = await import("./run-plan"); + const shared = path.join(tmpHome, ".nemoclaw"); + const sharedRegistryFile = path.join(shared, "sandboxes.json"); + fs.mkdirSync(shared, { recursive: true }); + fs.writeFileSync( + sharedRegistryFile, + JSON.stringify({ + defaultSandbox: "selected-box", + sandboxes: { + "selected-box": { + name: "selected-box", + gatewayName: `nemoclaw-${String(selectedPort)}`, + gatewayPort: selectedPort, + }, + "sibling-box": { + name: "sibling-box", + gatewayName: `nemoclaw-${String(siblingPort)}`, + gatewayPort: siblingPort, + }, + }, + }), + ); + + const migration = migrateLegacyPortState({ + gatewayPort: selectedPort, + home: tmpHome, + }); + expect(migration.migratedSandboxNames).toEqual(["selected-box"]); + const calls: Array<{ command: string; args: string[] }> = []; + const result = runPortUninstall( + { + assumeYes: true, + deleteModels: false, + destroyUserData: true, + gatewayName: `nemoclaw-${String(selectedPort)}`, + keepOpenShell: false, + }, + { + commandExists: (command) => command === "openshell", + env: { + HOME: tmpHome, + NEMOCLAW_GATEWAY_PORT: String(selectedPort), + } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: vi.fn(), + run: (command, args) => { + calls.push({ command, args }); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual([ + "gateway", + "select", + `nemoclaw-${String(selectedPort)}`, + ]); + expect(openshellCalls).toContainEqual(["sandbox", "delete", "selected-box"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(openshellCalls.some((args) => args[0] === "provider")).toBe(false); + expect(readGatewayRegistryFile(tmpHome, sharedRegistryFile)?.sandboxes).toEqual({ + "sibling-box": { + name: "sibling-box", + gatewayName: `nemoclaw-${String(siblingPort)}`, + gatewayPort: siblingPort, + }, + }); + expect(fs.existsSync(path.join(shared, "gateways", String(selectedPort)))).toBe(false); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("keeps shared legacy sibling state when uninstalling the default gateway", async () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-default-legacy-")); + const siblingPort = 9124; + try { + vi.stubEnv("NEMOCLAW_GATEWAY_PORT", "8080"); + vi.resetModules(); + const { runUninstallPlan: runDefaultUninstall } = await import("./run-plan"); + const shared = path.join(tmpHome, ".nemoclaw"); + const sharedRegistryFile = path.join(shared, "sandboxes.json"); + fs.mkdirSync(shared, { recursive: true }); + fs.writeFileSync(path.join(shared, "credentials.json"), "{}\n"); + fs.writeFileSync( + sharedRegistryFile, + JSON.stringify({ + defaultSandbox: "default-box", + sandboxes: { + "default-box": { + name: "default-box", + gatewayName: "nemoclaw", + gatewayPort: 8080, + }, + "sibling-box": { + name: "sibling-box", + gatewayName: `nemoclaw-${String(siblingPort)}`, + gatewayPort: siblingPort, + }, + }, + }), + ); + const calls: Array<{ command: string; args: string[] }> = []; + + const result = runDefaultUninstall( + { + assumeYes: true, + deleteModels: false, + destroyUserData: true, + gatewayName: "nemoclaw", + keepOpenShell: false, + }, + { + commandExists: (command) => command === "openshell", + env: { + HOME: tmpHome, + NEMOCLAW_GATEWAY_PORT: "8080", + } as NodeJS.ProcessEnv, + existsSync: (target) => target.startsWith(tmpHome) && fs.existsSync(target), + isTty: false, + log: vi.fn(), + run: (command, args) => { + calls.push({ command, args }); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "select", "nemoclaw"]); + expect(openshellCalls).toContainEqual(["sandbox", "delete", "default-box"]); + expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); + expect(openshellCalls.some((args) => args[0] === "provider")).toBe(false); + expect(readGatewayRegistryFile(tmpHome, sharedRegistryFile)?.sandboxes).toEqual({ + "sibling-box": { + name: "sibling-box", + gatewayName: `nemoclaw-${String(siblingPort)}`, + gatewayPort: siblingPort, + }, + }); + expect(fs.existsSync(path.join(shared, "credentials.json"))).toBe(true); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + it("uninstalls only the selected gateway while preserving host-shared and default resources", async () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-selected-only-")); const port = 9123; diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index b1ae22b5cda..a42496e5f20 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { dockerSpawnSync } from "../../adapters/docker/exec"; import { type AgentBranding, getAgentBranding } from "../../cli/branding"; +import { isErrnoException } from "../../core/errno"; import { DEFAULT_GATEWAY_PORT, GATEWAY_PORT } from "../../core/ports"; import { isStdinTty, readLineFromStdin } from "../../core/stdin"; import { sleepMs } from "../../core/wait"; @@ -28,7 +29,12 @@ import { resolveGatewayName } from "../../onboard/gateway-binding"; import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process"; import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process"; import { stopStaleDashboardListeners } from "../../onboard/stale-gateway-cleanup"; -import { readGatewayRegistryFile, registryEntryGatewayPort } from "../../state/gateway-registry"; +import { + assertGatewayStatePathSafe, + type GatewayRegistryDocument, + readGatewayRegistryFile, + registryEntryGatewayPort, +} from "../../state/gateway-registry"; import { GATEWAYS_SUBDIR } from "../../state/state-root"; import { stopOpenRouterRuntimeAdapter } from "./openrouter-runtime-adapter-cleanup"; import { classifyShimPath, type FileSystemDeps } from "./plan"; @@ -123,6 +129,29 @@ function pathEntryExists(target: string, runtime: Pick, +): SharedRegistrySiblingStatus { + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + const registryFile = path.join(sharedRoot, "sandboxes.json"); + if (!pathEntryExists(registryFile, runtime)) return "none"; + try { + const registry = readGatewayRegistryFile(path.dirname(sharedRoot), registryFile); + if (!registry) return "uncertain"; + return Object.values(registry.sandboxes).some( + (entry) => registryEntryGatewayPort(entry) !== GATEWAY_PORT, + ) + ? "present" + : "none"; + } catch { + // Unknown ownership must never permit host-global cleanup. + return "uncertain"; + } +} + function globToRegExp(pattern: string): RegExp { return new RegExp( `^${path @@ -705,6 +734,93 @@ function selectedRegistrySandboxNames(paths: UninstallPaths, runtime: UninstallR return names.sort(); } +function writeRegistryAtomic( + home: string, + registryFile: string, + registry: GatewayRegistryDocument, +): void { + assertGatewayStatePathSafe(home, path.dirname(registryFile)); + const tempFile = `${registryFile}.uninstall.${String(process.pid)}.${String(Date.now())}`; + let fd: number | null = null; + try { + fd = fs.openSync( + tempFile, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + 0o600, + ); + fs.writeFileSync(fd, `${JSON.stringify(registry, null, 2)}\n`); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = null; + fs.renameSync(tempFile, registryFile); + } finally { + if (fd !== null) fs.closeSync(fd); + try { + fs.rmSync(tempFile, { force: true }); + } catch { + // Best effort after an interrupted atomic write. + } + } +} + +function pruneSelectedRowsFromSharedRegistry( + paths: UninstallPaths, + expectedSelectedNames: readonly string[], + runtime: UninstallRuntime, +): boolean { + const sharedRoot = path.dirname(paths.managedSwapMarkerPath); + const home = path.dirname(sharedRoot); + const registryFile = path.join(sharedRoot, "sandboxes.json"); + const lock = `${registryFile}.lock`; + let acquired = false; + try { + assertGatewayStatePathSafe(home, lock); + fs.mkdirSync(lock, { mode: 0o700 }); + acquired = true; + + const registry = readGatewayRegistryFile(home, registryFile); + if (!registry) throw new Error(`${registryFile} disappeared during scoped uninstall`); + const selectedNames = Object.entries(registry.sandboxes) + .filter(([, entry]) => registryEntryGatewayPort(entry) === GATEWAY_PORT) + .map(([name]) => name) + .sort(); + if (JSON.stringify(selectedNames) !== JSON.stringify([...expectedSelectedNames].sort())) { + throw new Error(`${registryFile} changed during scoped uninstall`); + } + + if (selectedNames.length === 0) return true; + const remainingSandboxes = Object.fromEntries( + Object.entries(registry.sandboxes).filter( + ([, entry]) => registryEntryGatewayPort(entry) !== GATEWAY_PORT, + ), + ); + const defaultSandbox = + registry.defaultSandbox && Object.hasOwn(remainingSandboxes, registry.defaultSandbox) + ? registry.defaultSandbox + : (Object.keys(remainingSandboxes).sort()[0] ?? null); + writeRegistryAtomic(home, registryFile, { + ...registry, + defaultSandbox, + sandboxes: remainingSandboxes, + }); + runtime.log( + `Removed ${String(selectedNames.length)} selected-gateway row(s) from the shared sandbox registry.`, + ); + return true; + } catch (error) { + const detail = + isErrnoException(error) && error.code === "EEXIST" + ? `another state operation owns ${lock}` + : error instanceof Error + ? error.message + : String(error); + runtime.warn(`Could not safely update the shared sandbox registry: ${detail}`); + return false; + } finally { + if (acquired) fs.rmSync(lock, { recursive: true, force: true }); + } +} + function removeOpenShellResources( options: UninstallRunOptions, runtime: UninstallRuntime, @@ -948,57 +1064,76 @@ function removeOllamaModels(options: UninstallRunOptions, runtime: UninstallRunt } } -function otherGatewayEnvironmentsRemain(paths: UninstallPaths, runtime: UninstallRuntime): boolean { +interface OtherGatewayInspection { + otherGatewayEnvironmentsRemain: boolean; + sharedRegistryMustBePreserved: boolean; +} + +function inspectOtherGatewayEnvironments( + paths: UninstallPaths, + runtime: UninstallRuntime, +): OtherGatewayInspection { const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const selectedRoot = path.resolve(paths.nemoclawStateDir); const selectedIsDefault = selectedRoot === path.resolve(sharedRoot); + const sharedRegistryStatus = sharedRegistrySiblingStatus(paths, runtime); + if (sharedRegistryStatus !== "none") { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: true, + }; + } if (!selectedIsDefault && pathEntryExists(sharedRoot, runtime)) { - const defaultRegistryFile = path.join(sharedRoot, "sandboxes.json"); - if (pathEntryExists(defaultRegistryFile, runtime)) { - try { - const home = path.dirname(sharedRoot); - const defaultRegistry = readGatewayRegistryFile(home, defaultRegistryFile); - if ( - defaultRegistry && - Object.values(defaultRegistry.sandboxes).some( - (entry) => registryEntryGatewayPort(entry) === DEFAULT_GATEWAY_PORT, - ) - ) { - return true; - } - } catch { - // Malformed, unreadable, or symlinked shared registry state could be - // a live default environment. Preserve shared resources. - return true; - } - } try { if (fs.readdirSync(sharedRoot).some((entry) => !SHARED_HOST_STATE_ENTRIES.has(entry))) { - return true; + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; } } catch { // Do not remove a host-shared resource when we cannot prove that the // default-port environment is absent. - return true; + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; } } const gatewaysDir = path.join(sharedRoot, GATEWAYS_SUBDIR); - if (!pathEntryExists(gatewaysDir, runtime)) return false; + if (!pathEntryExists(gatewaysDir, runtime)) { + return { + otherGatewayEnvironmentsRemain: false, + sharedRegistryMustBePreserved: false, + }; + } try { const gatewaysStat = fs.lstatSync(gatewaysDir); - if (gatewaysStat.isSymbolicLink() || !gatewaysStat.isDirectory()) return true; - return fs.readdirSync(gatewaysDir, { withFileTypes: true }).some((entry) => { + if (gatewaysStat.isSymbolicLink() || !gatewaysStat.isDirectory()) { + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } + const siblingExists = fs.readdirSync(gatewaysDir, { withFileTypes: true }).some((entry) => { const candidate = path.resolve(gatewaysDir, entry.name); if (candidate === selectedRoot) return false; // Any other filesystem object is conservatively treated as gateway // state. In particular, never follow or dismiss a symlink here. return true; }); + return { + otherGatewayEnvironmentsRemain: siblingExists, + sharedRegistryMustBePreserved: false, + }; } catch { // An unreadable sibling registry is still potentially live. - return true; + return { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; } } @@ -1014,7 +1149,7 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo ); return; } - if (otherGatewayEnvironmentsRemain(paths, runtime)) { + if (inspectOtherGatewayEnvironments(paths, runtime).otherGatewayEnvironmentsRemain) { runtime.log( "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", ); @@ -1106,6 +1241,7 @@ function executePlan( runtime: UninstallRuntime, preserveUnderStateDir: readonly string[], scopedToSelectedGateway: boolean, + sharedRegistryMustBePreserved: boolean, sandboxNames: readonly string[], ): { ok: boolean } { let ok = true; @@ -1216,6 +1352,20 @@ function executePlan( } const sharedRoot = path.dirname(paths.managedSwapMarkerPath); const selectedIsDefault = path.resolve(paths.nemoclawStateDir) === path.resolve(sharedRoot); + if (scopedToSelectedGateway && selectedIsDefault && sharedRegistryMustBePreserved) { + if ( + !preserveUnderStateDir.includes("sandboxes.json") && + !pruneSelectedRowsFromSharedRegistry(paths, sandboxNames, runtime) + ) { + return { ok: false }; + } + removePath(paths.selectedGatewayLocalStateDir, runtime); + runtime.log( + "Legacy sibling gateway rows remain; kept the shared default-root state for their recovery.", + ); + runtime.log("Sibling gateways remain; kept shared OpenShell and NemoClaw config."); + continue; + } if ( !removePathExcept( paths.nemoclawStateDir, @@ -1280,7 +1430,8 @@ export function runUninstallPlan( } const resolvedOptions = { ...options, gatewayName: expectedGatewayName }; const { paths, plan } = buildRunPlan(resolvedOptions, { ...deps, env: runtime.env }); - const scopedToSelectedGateway = otherGatewayEnvironmentsRemain(paths, runtime); + const gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); + const { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; let sandboxNames: string[] = []; if (scopedToSelectedGateway) { try { @@ -1302,6 +1453,7 @@ export function runUninstallPlan( runtime, preserveUnderStateDir, scopedToSelectedGateway, + gatewayInspection.sharedRegistryMustBePreserved, sandboxNames, ); if (ok) { From 741e8168f9ca954279873f7910512831ad5b3f64 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 13:25:57 -0700 Subject: [PATCH 15/28] fix(state): harden gateway migration and installer Signed-off-by: Prekshi Vyas --- docs/reference/architecture.mdx | 2 +- scripts/install.sh | 128 +++++++++++++++++--- src/lib/state/legacy-port-migration.test.ts | 26 ++++ src/lib/state/legacy-port-migration.ts | 67 ++++++++-- test/install-gateway-state-root.test.ts | 57 +++++++++ 5 files changed, 254 insertions(+), 26 deletions(-) diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 2e53b7b3e8b..372e5676711 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -317,6 +317,7 @@ The following environment variables configure optional services and local access | Variable | Purpose | |---|---| +| `NEMOCLAW_GATEWAY_PORT` | Optional host-side gateway port override for an independent OpenShell gateway and port-scoped NemoClaw state root. Supported for OpenClaw, Hermes, and Deep Agents. | | `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `$$nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | | `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | @@ -339,7 +340,6 @@ The following environment variables configure optional services and local access | `NEMOCLAW_POLICY_TIER` | Optional non-interactive policy tier selection during onboarding. | | `TAVILY_API_KEY` | Host-side input for the optional managed Tavily provider. Register it with `$$nemoclaw credentials add tavily-search --type tavily --credential TAVILY_API_KEY` before attaching the provider to Deep Agents. | -| `NEMOCLAW_GATEWAY_PORT` | Optional host-side gateway port override when running multiple independent OpenShell gateways. | For normal setup and reconfiguration, prefer `$$nemoclaw onboard` over editing these files by hand. diff --git a/scripts/install.sh b/scripts/install.sh index 3d240d252da..1041118ab0a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -200,12 +200,45 @@ resolve_nemoclaw_gateway_port() { if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then error "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535." fi + if [ "$port" -ge 18789 ] && [ "$port" -le 18799 ]; then + error "NEMOCLAW_GATEWAY_PORT must not overlap the 18789-18799 dashboard port range." + fi + case "$port" in + 8000 | 11434 | 11435 | 11436 | 11437) + error "NEMOCLAW_GATEWAY_PORT must not overlap a reserved inference or runtime-adapter port ($port)." + ;; + esac + local -a configured_names=( + NEMOCLAW_DASHBOARD_PORT + NEMOCLAW_VLLM_PORT + NEMOCLAW_OLLAMA_PORT + NEMOCLAW_OLLAMA_PROXY_PORT + NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT + NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT + ) + local -a configured_ports=( + "${NEMOCLAW_DASHBOARD_PORT:-18789}" + "${NEMOCLAW_VLLM_PORT:-8000}" + "${NEMOCLAW_OLLAMA_PORT:-11434}" + "${NEMOCLAW_OLLAMA_PROXY_PORT:-11435}" + "${NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT:-11436}" + "${NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT:-11437}" + ) + local i configured_port + for i in "${!configured_ports[@]}"; do + configured_port="${configured_ports[$i]}" + configured_port="${configured_port#"${configured_port%%[![:space:]]*}"}" + configured_port="${configured_port%"${configured_port##*[![:space:]]}"}" + if [[ "$configured_port" =~ ^[0-9]+$ ]] && [ "$port" -eq "$configured_port" ]; then + error "NEMOCLAW_GATEWAY_PORT conflicts with ${configured_names[$i]} ($configured_port)." + fi + done printf "%s" "$port" } nemoclaw_state_dir() { local port - port="$(resolve_nemoclaw_gateway_port)" + port="$(resolve_nemoclaw_gateway_port)" || return 1 if [ "$port" -eq 8080 ]; then printf "%s/.nemoclaw" "$HOME" else @@ -213,9 +246,49 @@ nemoclaw_state_dir() { fi } +assert_nemoclaw_state_path_safe() { + local target="$1" root="${HOME}/.nemoclaw" current relative component + case "$target" in + "$root" | "$root"/*) ;; + *) error "Refusing NemoClaw state path outside ${root}: ${target}" ;; + esac + + current="$root" + if [ -L "$current" ]; then + error "Refusing symbolic link in NemoClaw state path: ${current}" + fi + relative="${target#"$root"}" + relative="${relative#/}" + while [ -n "$relative" ]; do + component="${relative%%/*}" + current="${current}/${component}" + if [ -L "$current" ]; then + error "Refusing symbolic link in NemoClaw state path: ${current}" + fi + if [ "$relative" = "$component" ]; then break; fi + relative="${relative#*/}" + done +} + +ensure_nemoclaw_state_dir() { + local state_dir root gateways_dir + state_dir="$(nemoclaw_state_dir)" || return 1 + root="${HOME}/.nemoclaw" + gateways_dir="${root}/gateways" + assert_nemoclaw_state_path_safe "$state_dir" + (umask 077 && mkdir -p "$state_dir") || error "Could not create NemoClaw state directory: ${state_dir}" + assert_nemoclaw_state_path_safe "$state_dir" + chmod 700 "$root" || error "Could not secure NemoClaw state directory: ${root}" + if [ "$state_dir" != "$root" ]; then + chmod 700 "$gateways_dir" "$state_dir" \ + || error "Could not secure gateway-scoped NemoClaw state directory: ${state_dir}" + fi + printf "%s" "$state_dir" +} + nemoclaw_gateway_name() { local port - port="$(resolve_nemoclaw_gateway_port)" + port="$(resolve_nemoclaw_gateway_port)" || return 1 if [ "$port" -eq 8080 ]; then printf "nemoclaw" else @@ -350,7 +423,7 @@ resolve_onboarded_agent() { } restore_onboard_forward_after_post_checks() { - local sandbox_name agent_name agent_display port openshell_bin attempt state_dir pid_file watcher_script watcher_pid + local sandbox_name agent_name agent_display port openshell_bin attempt selected_state_dir state_dir pid_file watcher_script watcher_pid sandbox_name="$(resolve_default_sandbox_name)" agent_name="$(resolve_onboarded_agent)" agent_display="$(agent_display_name "$agent_name")" @@ -368,8 +441,14 @@ restore_onboard_forward_after_post_checks() { return 0 fi - state_dir="$(nemoclaw_state_dir)/state" - mkdir -p "$state_dir" 2>/dev/null || true + selected_state_dir="$(ensure_nemoclaw_state_dir)" || return 1 + state_dir="${selected_state_dir}/state" + assert_nemoclaw_state_path_safe "$state_dir" + (umask 077 && mkdir -p "$state_dir") \ + || error "Could not create gateway-scoped runtime state directory: ${state_dir}" + assert_nemoclaw_state_path_safe "$state_dir" + chmod 700 "$state_dir" \ + || error "Could not secure gateway-scoped runtime state directory: ${state_dir}" pid_file="${state_dir}/${agent_name}-${sandbox_name}-${port}.forward.pid" if [[ -f "$pid_file" ]]; then local old_pid expected_watcher_script current_uid old_uid old_args @@ -758,26 +837,43 @@ json_string_field() { } usage_notice_state_file() { - printf "%s/usage-notice.json" "$(nemoclaw_state_dir)" + local state_dir + state_dir="$(nemoclaw_state_dir)" || return 1 + printf "%s/usage-notice.json" "$state_dir" } usage_notice_accepted_shell() { local version="$1" state_file saved_version - state_file="$(usage_notice_state_file)" + state_file="$(usage_notice_state_file)" || return 1 + assert_nemoclaw_state_path_safe "$state_file" [[ -n "$version" && -f "$state_file" ]] || return 1 saved_version="$(sed -nE 's/.*"acceptedVersion"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$state_file" | head -n 1)" [[ "$saved_version" == "$version" ]] } save_usage_notice_acceptance_shell() { - local version="$1" state_file state_dir accepted_at - state_file="$(usage_notice_state_file)" - state_dir="$(dirname "$state_file")" + local version="$1" state_file state_dir accepted_at temp_file + state_file="$(usage_notice_state_file)" || return 1 + state_dir="$(ensure_nemoclaw_state_dir)" || return 1 + assert_nemoclaw_state_path_safe "$state_file" accepted_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date)" - mkdir -p "$state_dir" - chmod 700 "$state_dir" 2>/dev/null || true - printf '{\n "acceptedVersion": "%s",\n "acceptedAt": "%s"\n}\n' "$version" "$accepted_at" >"$state_file" - chmod 600 "$state_file" 2>/dev/null || true + temp_file="$(mktemp "${state_file}.tmp.XXXXXX")" \ + || error "Could not create temporary usage-notice state under ${state_dir}." + chmod 600 "$temp_file" || { + rm -f "$temp_file" + error "Could not secure temporary usage-notice state under ${state_dir}." + } + if ! printf '{\n "acceptedVersion": "%s",\n "acceptedAt": "%s"\n}\n' \ + "$version" "$accepted_at" >"$temp_file"; then + rm -f "$temp_file" + error "Could not write usage-notice state under ${state_dir}." + fi + assert_nemoclaw_state_path_safe "$state_file" + if ! mv -f "$temp_file" "$state_file"; then + rm -f "$temp_file" + error "Could not publish usage-notice state under ${state_dir}." + fi + assert_nemoclaw_state_path_safe "$state_file" } print_usage_notice_body_shell() { @@ -2940,6 +3036,10 @@ main() { export NEMOCLAW_NON_INTERACTIVE="${NON_INTERACTIVE}" export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE}" + # Validate the gateway port before the banner, notice acceptance, downloads, + # or any other installer side effect. + resolve_nemoclaw_gateway_port >/dev/null + print_banner # Fail-fast license-acceptance check (#2671). Headless curl|bash still exits diff --git a/src/lib/state/legacy-port-migration.test.ts b/src/lib/state/legacy-port-migration.test.ts index bfdcf20091e..f0f6834b85c 100644 --- a/src/lib/state/legacy-port-migration.test.ts +++ b/src/lib/state/legacy-port-migration.test.ts @@ -121,6 +121,32 @@ describe("legacy non-default gateway state migration", () => { expect(fs.existsSync(path.join(shared, "gateways", "9123", "sandboxes.json"))).toBe(false); }); + it("preflights backup collisions before publishing the selected registry", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + const legacyRegistry = path.join(shared, "sandboxes.json"); + const selectedRegistry = path.join(selected, "sandboxes.json"); + writeJson(legacyRegistry, { + defaultSandbox: "port-box", + sandboxes: { + "port-box": { name: "port-box", gatewayName: "nemoclaw-9123", gatewayPort: 9123 }, + }, + }); + writeJson(path.join(shared, "rebuild-backups", "port-box", "old", "manifest.json"), {}); + writeJson(path.join(selected, "rebuild-backups", "port-box", "existing", "manifest.json"), {}); + const legacyBefore = fs.readFileSync(legacyRegistry, "utf8"); + + expect(() => migrateLegacyPortState({ home, gatewayPort: 9123 })).toThrow( + /already exists; refusing to overwrite/, + ); + expect(fs.existsSync(selectedRegistry)).toBe(false); + expect(fs.readFileSync(legacyRegistry, "utf8")).toBe(legacyBefore); + expect( + fs.existsSync(path.join(shared, "rebuild-backups", "port-box", "old", "manifest.json")), + ).toBe(true); + }); + it("partitions provable rows but leaves credentials whose gateway ownership is ambiguous", () => { const home = makeHome(); const shared = path.join(home, ".nemoclaw"); diff --git a/src/lib/state/legacy-port-migration.ts b/src/lib/state/legacy-port-migration.ts index 89772e9e083..2b3b0eb72a3 100644 --- a/src/lib/state/legacy-port-migration.ts +++ b/src/lib/state/legacy-port-migration.ts @@ -207,6 +207,43 @@ function movePath(home: string, source: string, destination: string): boolean { return true; } +function preflightMovePath(home: string, source: string, destination: string): boolean { + let sourceStat: fs.Stats; + try { + sourceStat = fs.lstatSync(source); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return false; + throw error; + } + if (sourceStat.isSymbolicLink()) throw migrationError(`${source} is a symbolic link`); + assertGatewayStatePathSafe(home, source); + assertGatewayStatePathSafe(home, destination); + try { + fs.lstatSync(destination); + throw migrationError(`${destination} already exists; refusing to overwrite it`); + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } + fs.accessSync(path.dirname(source), fs.constants.W_OK); + ensureRealDirectory(home, path.dirname(destination)); + return true; +} + +function preflightSandboxBackups( + home: string, + sharedRoot: string, + selectedRoot: string, + sandboxNames: readonly string[], +): void { + for (const sandboxName of sandboxNames) { + preflightMovePath( + home, + path.join(sharedRoot, "rebuild-backups", sandboxName), + path.join(selectedRoot, "rebuild-backups", sandboxName), + ); + } +} + function migrateSandboxBackups( home: string, sharedRoot: string, @@ -291,6 +328,25 @@ export function migrateLegacyPortState( if (selectedNames.length === 0 && !sessionBelongsToSelected) return result; + const entriesToMove: readonly string[] = wholeLegacyBundleBelongsToSelected + ? LEGACY_BUNDLE_ENTRIES + : sessionBelongsToSelected + ? SESSION_BOUND_ENTRIES + : []; + if (sessionBelongsToSelected) { + const activeLock = path.join(sharedRoot, "onboard.lock"); + if (fs.existsSync(activeLock)) { + throw migrationError( + `legacy onboarding lock ${activeLock} is present; finish or stop that run first`, + ); + } + preflightMovePath(home, legacySessionFile, path.join(selectedRoot, "onboard-session.json")); + } + preflightSandboxBackups(home, sharedRoot, selectedRoot, selectedNames); + for (const entry of entriesToMove) { + preflightMovePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); + } + registryLocks.push(acquireDirectoryLock(home, `${selectedRegistryFile}.lock`)); const existingSelected = readGatewayRegistryFile(home, selectedRegistryFile); const selectedRegistry = mergeSelectedRegistry( @@ -304,12 +360,6 @@ export function migrateLegacyPortState( migrateSandboxBackups(home, sharedRoot, selectedRoot, selectedNames); if (sessionBelongsToSelected) { - const activeLock = path.join(sharedRoot, "onboard.lock"); - if (fs.existsSync(activeLock)) { - throw migrationError( - `legacy onboarding lock ${activeLock} is present; finish or stop that run first`, - ); - } result.migratedSession = movePath( home, legacySessionFile, @@ -321,11 +371,6 @@ export function migrateLegacyPortState( ); } - const entriesToMove: readonly string[] = wholeLegacyBundleBelongsToSelected - ? LEGACY_BUNDLE_ENTRIES - : sessionBelongsToSelected - ? SESSION_BOUND_ENTRIES - : []; for (const entry of entriesToMove) { movePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); } diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index c07fb68673b..7f5eae27956 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -31,7 +31,13 @@ function runInstallerFunctions( BASH_ENV: "", ENV: "", HOME: home, + NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT: "", + NEMOCLAW_DASHBOARD_PORT: "", NEMOCLAW_GATEWAY_PORT: "9123", + NEMOCLAW_OLLAMA_PORT: "", + NEMOCLAW_OLLAMA_PROXY_PORT: "", + NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT: "", + NEMOCLAW_VLLM_PORT: "", }, }); return { output: `${result.stdout}${result.stderr}`, status: result.status }; @@ -114,6 +120,57 @@ printf 'agent=%s\n' "$(resolve_onboarded_agent)"`, } }); + it.each([ + "11434", + "18790", + ])("rejects conflicting gateway port %s before writing selected state", (gatewayPort) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-port-conflict-")); + try { + const result = runInstallerFunctions( + home, + `NEMOCLAW_GATEWAY_PORT=${gatewayPort} +save_usage_notice_acceptance_shell "test-version"`, + ); + + expect(result.status, result.output).not.toBe(0); + expect(result.output).toContain("NEMOCLAW_GATEWAY_PORT"); + expect(fs.existsSync(path.join(home, ".nemoclaw", "gateways", gatewayPort))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it.each([ + "gateways", + "selected-port", + ])("rejects a symlinked %s state ancestor before writing usage acceptance", (symlinkLocation) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-state-symlink-")); + try { + const root = path.join(home, ".nemoclaw"); + const controlled = path.join(home, "controlled"); + fs.mkdirSync(controlled); + if (symlinkLocation === "gateways") { + fs.mkdirSync(root); + fs.symlinkSync(controlled, path.join(root, "gateways"), "dir"); + } else { + fs.mkdirSync(path.join(root, "gateways"), { recursive: true }); + fs.symlinkSync(controlled, path.join(root, "gateways", "9123"), "dir"); + } + + const result = runInstallerFunctions( + home, + 'save_usage_notice_acceptance_shell "test-version"', + ); + + expect(result.status, result.output).not.toBe(0); + expect(result.output).toContain("Refusing symbolic link in NemoClaw state path"); + expect(fs.existsSync(path.join(controlled, "usage-notice.json"))).toBe(false); + expect(fs.existsSync(path.join(controlled, "9123", "usage-notice.json"))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("runs the one-time partition before a normal selected-port CLI command", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-legacy-port-")); try { From 00dba27c0122928df71022649145902a89e98d0e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 13:29:23 -0700 Subject: [PATCH 16/28] test(installer): keep symlink cases linear --- test/install-gateway-state-root.test.ts | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index 7f5eae27956..565494019c6 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -11,6 +11,23 @@ import { describe, expect, it } from "vitest"; const INSTALLER = path.join(import.meta.dirname, "..", "scripts", "install.sh"); const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); +const stateSymlinkCases = [ + { + label: "gateways", + setup(root: string, controlled: string): void { + fs.mkdirSync(root); + fs.symlinkSync(controlled, path.join(root, "gateways"), "dir"); + }, + }, + { + label: "selected-port", + setup(root: string, controlled: string): void { + fs.mkdirSync(path.join(root, "gateways"), { recursive: true }); + fs.symlinkSync(controlled, path.join(root, "gateways", "9123"), "dir"); + }, + }, +]; + function writeJson(filePath: string, value: unknown): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(value)); @@ -140,22 +157,15 @@ save_usage_notice_acceptance_shell "test-version"`, } }); - it.each([ - "gateways", - "selected-port", - ])("rejects a symlinked %s state ancestor before writing usage acceptance", (symlinkLocation) => { + it.each( + stateSymlinkCases, + )("rejects a symlinked $label state ancestor before writing usage acceptance", ({ setup }) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-state-symlink-")); try { const root = path.join(home, ".nemoclaw"); const controlled = path.join(home, "controlled"); fs.mkdirSync(controlled); - if (symlinkLocation === "gateways") { - fs.mkdirSync(root); - fs.symlinkSync(controlled, path.join(root, "gateways"), "dir"); - } else { - fs.mkdirSync(path.join(root, "gateways"), { recursive: true }); - fs.symlinkSync(controlled, path.join(root, "gateways", "9123"), "dir"); - } + setup(root, controlled); const result = runInstallerFunctions( home, From 1fda1120ab233fe4157456ce4323bb420d504fe7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 13:42:03 -0700 Subject: [PATCH 17/28] test(state): avoid external CLI in migration proof --- test/install-gateway-state-root.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index 565494019c6..874fe73b0fa 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -202,12 +202,13 @@ save_usage_notice_acceptance_shell "test-version"`, metadata: { gatewayName: "nemoclaw-9123" }, }); - const result = spawnSync(process.execPath, [CLI, "credentials", "list"], { + const result = spawnSync(process.execPath, [CLI, "agents", "list"], { encoding: "utf8", env: { ...process.env, HOME: home, NEMOCLAW_GATEWAY_PORT: "9123", + PATH: "", }, }); From 17f9343bd420d7c29b27bef49e5d16d114b33031 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:03:37 -0700 Subject: [PATCH 18/28] fix(state): close gateway migration safety gaps --- scripts/install.sh | 15 +- src/lib/state/legacy-port-migration.test.ts | 61 ++- src/lib/state/legacy-port-migration.ts | 504 +++++++++++++++--- test/install-openshell-upgrade-prompt.test.ts | 31 ++ 4 files changed, 533 insertions(+), 78 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 1041118ab0a..e98d8c832d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2105,13 +2105,18 @@ EOF } print_openshell_upgrade_manual_commands() { + local gateway_port gateway_name gateway_port_env="" + gateway_port="$(resolve_nemoclaw_gateway_port)" || return 1 + gateway_name="$(nemoclaw_gateway_name)" || return 1 + if [ "$gateway_port" -ne 8080 ]; then + gateway_port_env="NEMOCLAW_GATEWAY_PORT=${gateway_port} " + fi cat < { } afterEach(() => { + vi.restoreAllMocks(); for (const home of homes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); }); @@ -147,6 +148,64 @@ describe("legacy non-default gateway state migration", () => { ).toBe(true); }); + it("removes shared ownership before moves and resumes an interrupted migration", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const selected = path.join(shared, "gateways", "9123"); + const legacyRegistry = path.join(shared, "sandboxes.json"); + const selectedRegistry = path.join(selected, "sandboxes.json"); + const backupSource = path.join(shared, "rebuild-backups", "port-box"); + const backupDestination = path.join(selected, "rebuild-backups", "port-box"); + writeJson(legacyRegistry, { + defaultSandbox: "default-box", + sandboxes: { + "default-box": { name: "default-box", gatewayName: "nemoclaw", gatewayPort: 8080 }, + "port-box": { name: "port-box", gatewayName: "nemoclaw-9123", gatewayPort: 9123 }, + }, + }); + writeJson(path.join(backupSource, "snapshot", "manifest.json"), {}); + + const renameSync = fs.renameSync.bind(fs); + const failMove = (): never => { + throw new Error("injected post-registry move failure"); + }; + const renameSpy = vi + .spyOn(fs, "renameSync") + .mockImplementation((source, destination) => + String(source) === backupSource ? failMove() : renameSync(source, destination), + ); + + expect(() => migrateLegacyPortState({ home, gatewayPort: 9123 })).toThrow( + /injected post-registry move failure/, + ); + renameSpy.mockRestore(); + + expect(Object.keys(readJson(legacyRegistry).sandboxes as object)).toEqual(["default-box"]); + expect(fs.existsSync(selectedRegistry)).toBe(false); + expect(fs.existsSync(path.join(shared, ".gateway-state-migration"))).toBe(true); + expect(() => migrateLegacyPortState({ home, gatewayPort: 8080 })).toThrow( + /recoverable migration for gateway port 9123 is pending/, + ); + + for (const staleLock of [ + path.join(shared, ".gateway-state-migration.lock"), + `${legacyRegistry}.lock`, + `${selectedRegistry}.lock`, + ]) { + fs.mkdirSync(staleLock, { recursive: true }); + fs.writeFileSync(path.join(staleLock, "owner"), String(Number.MAX_SAFE_INTEGER)); + } + + expect(migrateLegacyPortState({ home, gatewayPort: 9123 })).toEqual({ + migratedSandboxNames: ["port-box"], + migratedSession: false, + warnings: [], + }); + expect(Object.keys(readJson(selectedRegistry).sandboxes as object)).toEqual(["port-box"]); + expect(fs.existsSync(path.join(backupDestination, "snapshot", "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(shared, ".gateway-state-migration"))).toBe(false); + }); + it("partitions provable rows but leaves credentials whose gateway ownership is ambiguous", () => { const home = makeHome(); const shared = path.join(home, ".nemoclaw"); diff --git a/src/lib/state/legacy-port-migration.ts b/src/lib/state/legacy-port-migration.ts index 2b3b0eb72a3..b431150567a 100644 --- a/src/lib/state/legacy-port-migration.ts +++ b/src/lib/state/legacy-port-migration.ts @@ -18,6 +18,12 @@ import { import { nemoclawStateRoot, resolveHome } from "./state-root"; const MIGRATION_LOCK = ".gateway-state-migration.lock"; +const MIGRATION_INTENT = ".gateway-state-migration"; +const MIGRATION_INTENT_METADATA = "intent.json"; +const MIGRATION_INTENT_SELECTED_REGISTRY = "selected-registry.json"; +const MIGRATION_INTENT_REMAINING_REGISTRY = "remaining-registry.json"; +const MIGRATION_INTENT_VERSION = 1; +const MIGRATION_LOCK_STALE_MS = 10_000; const MAX_MIGRATABLE_JSON_BYTES = 16 * 1024 * 1024; const LEGACY_BUNDLE_ENTRIES = [ "backups", @@ -33,6 +39,8 @@ const LEGACY_BUNDLE_ENTRIES = [ "usage-notice.json", ] as const; const SESSION_BOUND_ENTRIES = ["credentials.json"] as const; +type LegacyBundleEntry = (typeof LEGACY_BUNDLE_ENTRIES)[number]; +const LEGACY_BUNDLE_ENTRY_SET: ReadonlySet = new Set(LEGACY_BUNDLE_ENTRIES); export interface LegacyPortMigrationResult { migratedSandboxNames: string[]; @@ -40,6 +48,24 @@ export interface LegacyPortMigrationResult { warnings: string[]; } +interface LegacyPortMigrationIntentMetadata { + version: typeof MIGRATION_INTENT_VERSION; + gatewayPort: number; + selectedSandboxNames: string[]; + sandboxBackupNames: string[]; + moveSession: boolean; + bundleEntries: LegacyBundleEntry[]; + warnAmbiguousSession: boolean; + rewriteLegacyRegistry: boolean; +} + +interface LegacyPortMigrationIntent { + intentDir: string; + metadata: LegacyPortMigrationIntentMetadata; + selectedRegistry: GatewayRegistryDocument; + remainingRegistry: GatewayRegistryDocument | null; +} + function migrationError(message: string): Error { return new Error(`Cannot safely migrate legacy NemoClaw state for this gateway port: ${message}`); } @@ -53,6 +79,15 @@ function ensureRealDirectory(home: string, dir: string): void { if ((stat.mode & 0o077) !== 0) fs.chmodSync(dir, 0o700); } +function fsyncDirectory(dir: string): void { + const fd = fs.openSync(dir, fs.constants.O_RDONLY); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + function writeJsonAtomic(home: string, filePath: string, value: unknown): void { const dir = path.dirname(filePath); ensureRealDirectory(home, dir); @@ -76,6 +111,7 @@ function writeJsonAtomic(home: string, filePath: string, value: unknown): void { fs.closeSync(fd); fd = null; fs.renameSync(temp, filePath); + fsyncDirectory(dir); } finally { if (fd !== null) fs.closeSync(fd); try { @@ -188,23 +224,32 @@ function sessionGatewayPort( return metadataPort ?? rowPort; } -function movePath(home: string, source: string, destination: string): boolean { - let sourceStat: fs.Stats; +function lstatNoFollow(home: string, target: string): fs.Stats | null { + assertGatewayStatePathSafe(home, target); try { - sourceStat = fs.lstatSync(source); + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) throw migrationError(`${target} is a symbolic link`); + return stat; } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return false; + if (isErrnoException(error) && error.code === "ENOENT") return null; throw error; } - if (sourceStat.isSymbolicLink()) throw migrationError(`${source} is a symbolic link`); - assertGatewayStatePathSafe(home, source); - assertGatewayStatePathSafe(home, destination); - if (fs.existsSync(destination)) { +} + +function resumeMovePath(home: string, source: string, destination: string): void { + const sourceStat = lstatNoFollow(home, source); + const destinationStat = lstatNoFollow(home, destination); + if (sourceStat && destinationStat) { throw migrationError(`${destination} already exists; refusing to overwrite it`); } + if (!sourceStat && !destinationStat) { + throw migrationError(`both ${source} and its migration destination ${destination} are missing`); + } + if (!sourceStat) return; ensureRealDirectory(home, path.dirname(destination)); fs.renameSync(source, destination); - return true; + fsyncDirectory(path.dirname(source)); + fsyncDirectory(path.dirname(destination)); } function preflightMovePath(home: string, source: string, destination: string): boolean { @@ -234,14 +279,20 @@ function preflightSandboxBackups( sharedRoot: string, selectedRoot: string, sandboxNames: readonly string[], -): void { +): string[] { + const existing: string[] = []; for (const sandboxName of sandboxNames) { - preflightMovePath( - home, - path.join(sharedRoot, "rebuild-backups", sandboxName), - path.join(selectedRoot, "rebuild-backups", sandboxName), - ); + if ( + preflightMovePath( + home, + path.join(sharedRoot, "rebuild-backups", sandboxName), + path.join(selectedRoot, "rebuild-backups", sandboxName), + ) + ) { + existing.push(sandboxName); + } } + return existing; } function migrateSandboxBackups( @@ -251,7 +302,7 @@ function migrateSandboxBackups( sandboxNames: readonly string[], ): void { for (const sandboxName of sandboxNames) { - movePath( + resumeMovePath( home, path.join(sharedRoot, "rebuild-backups", sandboxName), path.join(selectedRoot, "rebuild-backups", sandboxName), @@ -259,18 +310,287 @@ function migrateSandboxBackups( } } -function acquireDirectoryLock(home: string, lock: string): string { - ensureRealDirectory(home, path.dirname(lock)); +function parseUniqueStringArray(value: unknown, label: string): string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string" || entry.length === 0) || + new Set(value).size !== value.length + ) { + throw migrationError(`${label} is not a unique string array`); + } + return value as string[]; +} + +function readMigrationIntent(home: string, sharedRoot: string): LegacyPortMigrationIntent | null { + const intentDir = path.join(sharedRoot, MIGRATION_INTENT); + const intentStat = lstatNoFollow(home, intentDir); + if (!intentStat) return null; + if (!intentStat.isDirectory()) throw migrationError(`${intentDir} is not a directory`); + + const rawMetadata = readJsonNoFollow(home, path.join(intentDir, MIGRATION_INTENT_METADATA)); + if (!isObjectRecord(rawMetadata)) { + throw migrationError(`${path.join(intentDir, MIGRATION_INTENT_METADATA)} is not an object`); + } + const gatewayPort = rawMetadata.gatewayPort; + if ( + rawMetadata.version !== MIGRATION_INTENT_VERSION || + typeof gatewayPort !== "number" || + !Number.isInteger(gatewayPort) || + gatewayPort < 1 || + gatewayPort > 65535 || + gatewayPort === DEFAULT_GATEWAY_PORT || + typeof rawMetadata.moveSession !== "boolean" || + typeof rawMetadata.warnAmbiguousSession !== "boolean" || + typeof rawMetadata.rewriteLegacyRegistry !== "boolean" + ) { + throw migrationError(`${path.join(intentDir, MIGRATION_INTENT_METADATA)} is invalid`); + } + + const selectedSandboxNames = parseUniqueStringArray( + rawMetadata.selectedSandboxNames, + "migration intent selectedSandboxNames", + ); + const sandboxBackupNames = parseUniqueStringArray( + rawMetadata.sandboxBackupNames, + "migration intent sandboxBackupNames", + ); + const rawBundleEntries = parseUniqueStringArray( + rawMetadata.bundleEntries, + "migration intent bundleEntries", + ); + if ( + rawMetadata.rewriteLegacyRegistry !== selectedSandboxNames.length > 0 || + (rawMetadata.moveSession && rawMetadata.warnAmbiguousSession) || + (selectedSandboxNames.length === 0 && !rawMetadata.moveSession) + ) { + throw migrationError("migration intent has inconsistent ownership metadata"); + } + for (const entry of rawBundleEntries) { + if (!LEGACY_BUNDLE_ENTRY_SET.has(entry)) { + throw migrationError(`migration intent contains unsupported bundle entry ${entry}`); + } + } + const selectedNameSet = new Set(selectedSandboxNames); + for (const sandboxName of sandboxBackupNames) { + if (!selectedNameSet.has(sandboxName)) { + throw migrationError(`migration intent backup ${sandboxName} is not a selected sandbox`); + } + } + + const selectedRegistryFile = path.join(intentDir, MIGRATION_INTENT_SELECTED_REGISTRY); + const selectedRegistry = readGatewayRegistryFile(home, selectedRegistryFile); + if (!selectedRegistry) throw migrationError(`${selectedRegistryFile} is missing`); + for (const entry of Object.values(selectedRegistry.sandboxes)) { + if (registryEntryGatewayPort(entry) !== gatewayPort) { + throw migrationError(`${selectedRegistryFile} contains a sandbox for another gateway`); + } + } + for (const sandboxName of selectedSandboxNames) { + if (!Object.hasOwn(selectedRegistry.sandboxes, sandboxName)) { + throw migrationError(`${selectedRegistryFile} is missing selected sandbox ${sandboxName}`); + } + } + + let remainingRegistry: GatewayRegistryDocument | null = null; + if (rawMetadata.rewriteLegacyRegistry) { + const remainingRegistryFile = path.join(intentDir, MIGRATION_INTENT_REMAINING_REGISTRY); + remainingRegistry = readGatewayRegistryFile(home, remainingRegistryFile); + if (!remainingRegistry) throw migrationError(`${remainingRegistryFile} is missing`); + for (const entry of Object.values(remainingRegistry.sandboxes)) { + if (registryEntryGatewayPort(entry) === gatewayPort) { + throw migrationError(`${remainingRegistryFile} still contains a selected gateway sandbox`); + } + } + for (const sandboxName of selectedSandboxNames) { + if (Object.hasOwn(remainingRegistry.sandboxes, sandboxName)) { + throw migrationError( + `${remainingRegistryFile} still contains selected sandbox ${sandboxName}`, + ); + } + } + } + + return { + intentDir, + metadata: { + version: MIGRATION_INTENT_VERSION, + gatewayPort, + selectedSandboxNames, + sandboxBackupNames, + moveSession: rawMetadata.moveSession, + bundleEntries: rawBundleEntries as LegacyBundleEntry[], + warnAmbiguousSession: rawMetadata.warnAmbiguousSession, + rewriteLegacyRegistry: rawMetadata.rewriteLegacyRegistry, + }, + selectedRegistry, + remainingRegistry, + }; +} + +function createMigrationIntent( + home: string, + sharedRoot: string, + metadata: LegacyPortMigrationIntentMetadata, + selectedRegistry: GatewayRegistryDocument, + remainingRegistry: GatewayRegistryDocument | null, +): LegacyPortMigrationIntent { + const intentDir = path.join(sharedRoot, MIGRATION_INTENT); + if (lstatNoFollow(home, intentDir)) { + throw migrationError( + `${intentDir} already exists; resume it before starting another migration`, + ); + } + if (metadata.rewriteLegacyRegistry && !remainingRegistry) { + throw migrationError("migration intent is missing its remaining legacy registry"); + } + + ensureRealDirectory(home, sharedRoot); + const preparingDir = `${intentDir}.preparing.${String(process.pid)}.${String(Date.now())}`; + assertGatewayStatePathSafe(home, preparingDir); + fs.mkdirSync(preparingDir, { mode: 0o700 }); + try { + writeJsonAtomic(home, path.join(preparingDir, MIGRATION_INTENT_METADATA), metadata); + writeJsonAtomic( + home, + path.join(preparingDir, MIGRATION_INTENT_SELECTED_REGISTRY), + selectedRegistry, + ); + if (remainingRegistry) { + writeJsonAtomic( + home, + path.join(preparingDir, MIGRATION_INTENT_REMAINING_REGISTRY), + remainingRegistry, + ); + } + fsyncDirectory(preparingDir); + fs.renameSync(preparingDir, intentDir); + fsyncDirectory(sharedRoot); + } finally { + fs.rmSync(preparingDir, { recursive: true, force: true }); + } + + const intent = readMigrationIntent(home, sharedRoot); + if (!intent) throw migrationError(`failed to publish ${intentDir}`); + return intent; +} + +function removeMigrationIntent(home: string, sharedRoot: string, intentDir: string): void { + const completedDir = `${intentDir}.completed.${String(process.pid)}.${String(Date.now())}`; + assertGatewayStatePathSafe(home, completedDir); + fs.renameSync(intentDir, completedDir); + fsyncDirectory(sharedRoot); + fs.rmSync(completedDir, { recursive: true, force: true }); + fsyncDirectory(sharedRoot); +} + +function applyMigrationIntent( + home: string, + sharedRoot: string, + selectedRoot: string, + legacyRegistryFile: string, + selectedRegistryFile: string, + intent: LegacyPortMigrationIntent, +): LegacyPortMigrationResult { + const result: LegacyPortMigrationResult = { + migratedSandboxNames: [...intent.metadata.selectedSandboxNames], + migratedSession: intent.metadata.moveSession, + warnings: [], + }; + + if (intent.metadata.rewriteLegacyRegistry) { + if (!intent.remainingRegistry) { + throw migrationError("migration intent is missing its remaining legacy registry"); + } + writeJsonAtomic(home, legacyRegistryFile, intent.remainingRegistry); + } + + migrateSandboxBackups(home, sharedRoot, selectedRoot, intent.metadata.sandboxBackupNames); + if (intent.metadata.moveSession) { + resumeMovePath( + home, + path.join(sharedRoot, "onboard-session.json"), + path.join(selectedRoot, "onboard-session.json"), + ); + } else if (intent.metadata.warnAmbiguousSession) { + result.warnings.push( + `Left ambiguous ${path.join(sharedRoot, "onboard-session.json")} in place because it has no recorded gateway identity.`, + ); + } + + for (const entry of intent.metadata.bundleEntries) { + resumeMovePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); + } + + const movedEntries = new Set(intent.metadata.bundleEntries); + const entriesLeftAmbiguous = LEGACY_BUNDLE_ENTRIES.filter( + (entry) => !movedEntries.has(entry) && lstatNoFollow(home, path.join(sharedRoot, entry)), + ); + if (intent.metadata.selectedSandboxNames.length > 0 && entriesLeftAmbiguous.length > 0) { + result.warnings.push( + `Left ambiguous legacy state under ${sharedRoot}: ${entriesLeftAmbiguous.join(", ")}. Review ownership before migrating or removing it; NemoClaw did not copy it into ${selectedRoot}.`, + ); + } + + writeJsonAtomic(home, selectedRegistryFile, intent.selectedRegistry); + removeMigrationIntent(home, sharedRoot, intent.intentDir); + return result; +} + +function isProcessAlive(pid: number): boolean { try { - fs.mkdirSync(lock, { mode: 0o700 }); - fs.writeFileSync(path.join(lock, "owner"), String(process.pid), { mode: 0o600 }); - return lock; + process.kill(pid, 0); + return true; } catch (error) { - if (isErrnoException(error) && error.code === "EEXIST") { + return isErrnoException(error) && error.code === "EPERM"; + } +} + +function existingLockIsStale(home: string, lock: string): boolean { + const stat = lstatNoFollow(home, lock); + if (!stat) return true; + if (!stat.isDirectory()) throw migrationError(`${lock} is not a directory`); + + let ownerPid: number | null = null; + try { + const ownerFile = path.join(lock, "owner"); + const ownerStat = lstatNoFollow(home, ownerFile); + if (ownerStat?.isFile()) { + const parsed = Number.parseInt(fs.readFileSync(ownerFile, "utf8").trim(), 10); + ownerPid = Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } + if (ownerPid !== null) return !isProcessAlive(ownerPid); + return Date.now() - stat.mtimeMs > MIGRATION_LOCK_STALE_MS; +} + +function acquireDirectoryLock(home: string, lock: string): string { + const parent = path.dirname(lock); + ensureRealDirectory(home, parent); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fs.mkdirSync(lock, { mode: 0o700 }); + try { + fs.writeFileSync(path.join(lock, "owner"), String(process.pid), { mode: 0o600 }); + fsyncDirectory(lock); + fsyncDirectory(parent); + return lock; + } catch (error) { + fs.rmSync(lock, { recursive: true, force: true }); + throw error; + } + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") throw error; + if (attempt === 0 && existingLockIsStale(home, lock)) { + fs.rmSync(lock, { recursive: true, force: true }); + fsyncDirectory(parent); + continue; + } throw migrationError(`another state operation owns ${lock}; retry after it completes`); } - throw error; } + throw migrationError(`could not acquire ${lock}`); } /** @@ -289,21 +609,59 @@ export function migrateLegacyPortState( migratedSession: false, warnings: [], }; - if (gatewayPort === DEFAULT_GATEWAY_PORT) return result; - const sharedRoot = nemoclawStateRoot(home, DEFAULT_GATEWAY_PORT); - const selectedRoot = nemoclawStateRoot(home, gatewayPort); const legacyRegistryFile = path.join(sharedRoot, "sandboxes.json"); + const migrationLock = path.join(sharedRoot, MIGRATION_LOCK); + const pendingBeforeLock = readMigrationIntent(home, sharedRoot); + + if (gatewayPort === DEFAULT_GATEWAY_PORT) { + if (pendingBeforeLock) { + throw migrationError( + `a recoverable migration for gateway port ${String(pendingBeforeLock.metadata.gatewayPort)} is pending; rerun a stateful command with NEMOCLAW_GATEWAY_PORT=${String(pendingBeforeLock.metadata.gatewayPort)} before using the default gateway`, + ); + } + if (lstatNoFollow(home, migrationLock)) { + if (existingLockIsStale(home, migrationLock)) { + fs.rmSync(migrationLock, { recursive: true, force: true }); + fsyncDirectory(sharedRoot); + } else { + throw migrationError( + "another gateway-state migration is in progress; retry after it completes", + ); + } + } + return result; + } + + const selectedRoot = nemoclawStateRoot(home, gatewayPort); const selectedRegistryFile = path.join(selectedRoot, "sandboxes.json"); const legacyRegistry = readGatewayRegistryFile(home, legacyRegistryFile); const legacySessionFile = path.join(sharedRoot, "onboard-session.json"); const legacySession = readJsonNoFollow(home, legacySessionFile); - if (!legacyRegistry && legacySession === null) return result; + if (!pendingBeforeLock && !legacyRegistry && legacySession === null) return result; - const lock = acquireDirectoryLock(home, path.join(sharedRoot, MIGRATION_LOCK)); + const lock = acquireDirectoryLock(home, migrationLock); const registryLocks: string[] = []; try { registryLocks.push(acquireDirectoryLock(home, `${legacyRegistryFile}.lock`)); + const pendingIntent = readMigrationIntent(home, sharedRoot); + if (pendingIntent) { + if (pendingIntent.metadata.gatewayPort !== gatewayPort) { + throw migrationError( + `a recoverable migration for gateway port ${String(pendingIntent.metadata.gatewayPort)} is pending; rerun with NEMOCLAW_GATEWAY_PORT=${String(pendingIntent.metadata.gatewayPort)}`, + ); + } + registryLocks.push(acquireDirectoryLock(home, `${selectedRegistryFile}.lock`)); + return applyMigrationIntent( + home, + sharedRoot, + selectedRoot, + legacyRegistryFile, + selectedRegistryFile, + pendingIntent, + ); + } + // Re-read under the shared lock so classification and writes use one view. const currentLegacy = readGatewayRegistryFile(home, legacyRegistryFile); const registryPortsByName = new Map(); @@ -328,23 +686,36 @@ export function migrateLegacyPortState( if (selectedNames.length === 0 && !sessionBelongsToSelected) return result; - const entriesToMove: readonly string[] = wholeLegacyBundleBelongsToSelected + const entriesToMove: readonly LegacyBundleEntry[] = wholeLegacyBundleBelongsToSelected ? LEGACY_BUNDLE_ENTRIES : sessionBelongsToSelected ? SESSION_BOUND_ENTRIES : []; + let moveSession = false; if (sessionBelongsToSelected) { const activeLock = path.join(sharedRoot, "onboard.lock"); - if (fs.existsSync(activeLock)) { + if (lstatNoFollow(home, activeLock)) { throw migrationError( `legacy onboarding lock ${activeLock} is present; finish or stop that run first`, ); } - preflightMovePath(home, legacySessionFile, path.join(selectedRoot, "onboard-session.json")); + moveSession = preflightMovePath( + home, + legacySessionFile, + path.join(selectedRoot, "onboard-session.json"), + ); } - preflightSandboxBackups(home, sharedRoot, selectedRoot, selectedNames); + const sandboxBackupNames = preflightSandboxBackups( + home, + sharedRoot, + selectedRoot, + selectedNames, + ); + const bundleEntries: LegacyBundleEntry[] = []; for (const entry of entriesToMove) { - preflightMovePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); + if (preflightMovePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry))) { + bundleEntries.push(entry); + } } registryLocks.push(acquireDirectoryLock(home, `${selectedRegistryFile}.lock`)); @@ -355,46 +726,35 @@ export function migrateLegacyPortState( existingSelected, gatewayPort, ); - writeJsonAtomic(home, selectedRegistryFile, selectedRegistry); - - migrateSandboxBackups(home, sharedRoot, selectedRoot, selectedNames); - - if (sessionBelongsToSelected) { - result.migratedSession = movePath( - home, - legacySessionFile, - path.join(selectedRoot, "onboard-session.json"), - ); - } else if (session !== null && recordedSessionPort === null && selectedNames.length > 0) { - result.warnings.push( - `Left ambiguous ${legacySessionFile} in place because it has no recorded gateway identity.`, - ); - } - - for (const entry of entriesToMove) { - movePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry)); - } - - const movedEntries = new Set(entriesToMove); - const entriesLeftAmbiguous = LEGACY_BUNDLE_ENTRIES.filter( - (entry) => !movedEntries.has(entry) && fs.existsSync(path.join(sharedRoot, entry)), + const remainingRegistry = + currentLegacy && selectedNames.length > 0 + ? registryWithSandboxes(currentLegacy, remainingEntries, currentLegacy.defaultSandbox) + : null; + const intent = createMigrationIntent( + home, + sharedRoot, + { + version: MIGRATION_INTENT_VERSION, + gatewayPort, + selectedSandboxNames: selectedNames, + sandboxBackupNames, + moveSession, + bundleEntries, + warnAmbiguousSession: + session !== null && recordedSessionPort === null && selectedNames.length > 0, + rewriteLegacyRegistry: remainingRegistry !== null, + }, + selectedRegistry, + remainingRegistry, + ); + return applyMigrationIntent( + home, + sharedRoot, + selectedRoot, + legacyRegistryFile, + selectedRegistryFile, + intent, ); - if (selectedNames.length > 0 && entriesLeftAmbiguous.length > 0) { - result.warnings.push( - `Left ambiguous legacy state under ${sharedRoot}: ${entriesLeftAmbiguous.join(", ")}. Review ownership before migrating or removing it; NemoClaw did not copy it into ${selectedRoot}.`, - ); - } - - if (currentLegacy && selectedNames.length > 0) { - const remainingRegistry = registryWithSandboxes( - currentLegacy, - remainingEntries, - currentLegacy.defaultSandbox, - ); - writeJsonAtomic(home, legacyRegistryFile, remainingRegistry); - } - result.migratedSandboxNames = selectedNames; - return result; } finally { for (const registryLock of registryLocks.reverse()) { fs.rmSync(registryLock, { recursive: true, force: true }); diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 22567449ee1..59969cb185e 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -139,6 +139,37 @@ describe("install.sh OpenShell gateway upgrade guard", () => { expect(openshellLog).toBe(""); }); + it("scopes non-default manual upgrade commands to the selected gateway", () => { + const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( + { + NON_INTERACTIVE: "1", + NEMOCLAW_GATEWAY_PORT: "9123", + }, + { + registryJson: + '{"sandboxes":{"alpha":{"name":"alpha","gatewayName":"nemoclaw-9123","gatewayPort":9123}}}', + }, + ); + + const output = result.stdout + result.stderr; + expect(result.status).not.toBe(0); + expect(output).toContain( + "NEMOCLAW_GATEWAY_PORT=9123 NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS=1 nemoclaw backup-all", + ); + expect(output).toContain( + "openshell gateway remove nemoclaw-9123 || openshell gateway destroy -g nemoclaw-9123", + ); + expect(output).toContain( + "curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_GATEWAY_PORT=9123 NEMOCLAW_OPENSHELL_UPGRADE_PREPARED=1 bash", + ); + expect(output).toContain("NEMOCLAW_GATEWAY_PORT=9123 nemoclaw upgrade-sandboxes --check"); + expect(output).not.toContain("openshell gateway remove nemoclaw ||"); + expect(output).not.toContain("|| openshell gateway destroy\n"); + expect(output).not.toContain("pkill -f openshell-gateway"); + expect(cliLog).toBe(""); + expect(openshellLog).toBe(""); + }); + it("requires separate managed-image confirmation before preparing a backup (#6114)", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard({ NON_INTERACTIVE: "1", From 91a5b01ce76cd3fe00bd875d91cf4b65512fc465 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:08:39 -0700 Subject: [PATCH 19/28] fix(installer): fail closed on registry inspection --- scripts/install.sh | 6 ++++-- test/install-preexisting-sandbox-recovery.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index e98d8c832d5..993c626d898 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3092,8 +3092,10 @@ main() { step 3 "Onboarding" if [ -n "$_cli_runner" ]; then local _registered_sandbox_count="" - if _registered_sandbox_count="$(registered_sandbox_count)" \ - && [[ "$_registered_sandbox_count" -gt 0 ]]; then + if ! _registered_sandbox_count="$(registered_sandbox_count)"; then + error "Could not inspect the existing sandbox registry. Onboarding was not started." + fi + if [[ "$_registered_sandbox_count" -gt 0 ]]; then warn "Existing sandbox sessions detected. Onboarding may disrupt running agents." if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then error "Aborting — NEMOCLAW_SINGLE_SESSION is set. Destroy existing sessions with '${_CLI_BIN} destroy' before reinstalling." diff --git a/test/install-preexisting-sandbox-recovery.test.ts b/test/install-preexisting-sandbox-recovery.test.ts index 70b53edef40..052217f3754 100644 --- a/test/install-preexisting-sandbox-recovery.test.ts +++ b/test/install-preexisting-sandbox-recovery.test.ts @@ -134,4 +134,16 @@ describe("install.sh pre-existing sandbox recovery ordering (#6114)", () => { expect(result.calls).toEqual(["restore=1 confirmed= argv=onboard"]); expect(result.output).not.toContain("Existing sandbox sessions detected"); }); + + it("stops before onboarding when the existing registry cannot be inspected", () => { + const result = runRecoveryBeforeOnboard(0, 0, { + registryJson: '{"sandboxes":{"broken":null}}', + }); + + expect(result.status).toBe(1); + expect(result.calls).toEqual([]); + expect(result.output).toContain( + "Could not inspect the existing sandbox registry. Onboarding was not started.", + ); + }); }); From a4fe26ff9369dfe8d47f7c740c8118528c66ee6d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:20:11 -0700 Subject: [PATCH 20/28] test(state): harden cleanup regressions --- test/mcp-lifecycle-lock.test.ts | 2 +- test/uninstall.test.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 1a2639b5472..79599b94ed5 100644 --- a/test/mcp-lifecycle-lock.test.ts +++ b/test/mcp-lifecycle-lock.test.ts @@ -442,7 +442,7 @@ const releasePath = process.argv[3]; lifecycleLock.withMcpLifecycleLock( "alpha", () => "acquired", - options({ timeoutMs: 200, corruptLockGraceMs: 20 }), + options({ corruptLockGraceMs: 20 }), ), ).resolves.toBe("acquired"); expect(fs.existsSync(lockPath)).toBe(false); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 40825d4ab1f..c85860eb337 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -28,7 +28,10 @@ describe("uninstall CLI flags", () => { ); fs.mkdirSync(path.join(stateDir, "backups", "20260320-120000"), { recursive: true }); fs.writeFileSync(path.join(stateDir, "backups", "20260320-120000", "USER.md"), "hello"); - fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ defaultSandbox: null, sandboxes: {} }), + ); return stateDir; } @@ -96,9 +99,9 @@ describe("uninstall CLI flags", () => { writeFakeTools(path.join(tmp, "bin")); try { const result = runUninstall(tmp, ["--yes"]); - - expect(result.status).toBe(0); const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); expect(output).toMatch(/NemoClaw/); expect(output).toMatch(/Claws retracted/); } finally { @@ -112,8 +115,9 @@ describe("uninstall CLI flags", () => { const stateDir = seedPreservedState(tmp); try { const result = runUninstall(tmp, ["--yes"]); + const output = `${result.stdout}${result.stderr}`; - expect(result.status).toBe(0); + expect(result.status, output).toBe(0); expect( fs.existsSync(path.join(stateDir, "rebuild-backups", "sb1", "20260101", "manifest.json")), ).toBe(true); @@ -132,9 +136,9 @@ describe("uninstall CLI flags", () => { const stateDir = seedPreservedState(tmp); try { const result = runUninstall(tmp, ["--yes", "--destroy-user-data"]); - - expect(result.status).toBe(0); const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); expect(output).toMatch(/--destroy-user-data set; purging user data under ~\/\.nemoclaw\//); expect(fs.existsSync(stateDir)).toBe(false); } finally { From b92e8ac01126c01482af2a4b1841e0f15161c5e4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:29:34 -0700 Subject: [PATCH 21/28] test(uninstall): use valid preserved registry fixture --- .../actions/uninstall/run-plan-preserved-registry.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts index e4e45886067..37a4bed2aa9 100644 --- a/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts +++ b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts @@ -22,7 +22,13 @@ function setupStateDir(): { tmpHome: string; stateDir: string } { const stateDir = path.join(tmpHome, ".nemoclaw"); fs.mkdirSync(path.join(stateDir, "rebuild-backups"), { recursive: true }); fs.mkdirSync(path.join(stateDir, "backups"), { recursive: true }); - fs.writeFileSync(path.join(stateDir, "sandboxes.json"), "[]"); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "preserved-box", + sandboxes: { "preserved-box": { name: "preserved-box" } }, + }), + ); return { tmpHome, stateDir }; } From d9532bee9f048841010b3f238701ba3d5aeafd7c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:36:19 -0700 Subject: [PATCH 22/28] fix(uninstall): remove current gateway registration --- .../run-plan-gateway-segregation.test.ts | 8 +-- src/lib/actions/uninstall/run-plan.test.ts | 67 +++++++++++++++++-- src/lib/actions/uninstall/run-plan.ts | 58 ++++++++++++---- 3 files changed, 106 insertions(+), 27 deletions(-) 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 a05193403e7..e2566298dd6 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -510,12 +510,8 @@ describe("uninstall gateway-port segregation (#3053)", () => { .map(({ args }) => args); expect(openshellCalls).toContainEqual(["gateway", "select", `nemoclaw-${String(port)}`]); expect(openshellCalls).toContainEqual(["sandbox", "delete", "port-box"]); - expect(openshellCalls).toContainEqual([ - "gateway", - "destroy", - "-g", - `nemoclaw-${String(port)}`, - ]); + expect(openshellCalls).toContainEqual(["gateway", "remove", `nemoclaw-${String(port)}`]); + expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false); expect(openshellCalls).not.toContainEqual(["sandbox", "delete", "--all"]); expect(openshellCalls.some((args) => args[0] === "provider")).toBe(false); expect(runCalls.some(({ command }) => command === "npm" || command === "ollama")).toBe(false); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 04da5ee3bfd..b839d137b57 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -935,11 +935,7 @@ describe("uninstall run plan", () => { expect(logs).not.toContain("Swap file removed"); }); - it("uses the 'already removed' wording instead of 'Destroyed ... skipped' for gateway destroy no-ops, sub-bug 4 (#3456)", () => { - // When `openshell gateway destroy -g nemoclaw` returns non-zero (gateway - // already gone), the previous code printed `Destroyed gateway 'nemoclaw' - // skipped` — self-contradictory. The fix routes this branch to an onSkip - // message that describes the actual state. + it("uses the 'already removed' wording for gateway remove no-ops, sub-bug 4 (#3456)", () => { const warnings: string[] = []; const logs: string[] = []; const result = runUninstallPlan( @@ -953,8 +949,8 @@ describe("uninstall run plan", () => { log: (line) => logs.push(line), rmSync: vi.fn(), run: (command, args) => { - if (command === "openshell" && args[0] === "gateway" && args[1] === "destroy") { - return notFound(); + if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { + return { status: 1, stdout: "", stderr: "gateway not found" }; } if (args[0] === "-c") return ok("/fake/bin/tool\n"); return ok(); @@ -970,6 +966,63 @@ describe("uninstall run plan", () => { ); }); + it("falls back to legacy gateway destroy only when gateway remove is unsupported", () => { + const calls: string[][] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + rmSync: vi.fn(), + run: (command, args) => { + if (command === "openshell") calls.push(args); + if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { + return { status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" }; + } + if (args[0] === "-c") return ok("/fake/bin/tool\n"); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(calls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(calls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); + }); + + it("does not hide a current gateway remove failure behind the legacy verb", () => { + const calls: string[][] = []; + const warnings: string[] = []; + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + rmSync: vi.fn(), + run: (command, args) => { + if (command === "openshell") calls.push(args); + if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { + return { status: 1, stdout: "", stderr: "permission denied" }; + } + if (args[0] === "-c") return ok("/fake/bin/tool\n"); + return ok(); + }, + runDocker: () => ok(""), + error: (line) => warnings.push(line), + }, + ); + + expect(result.exitCode).toBe(0); + expect(calls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(calls.some((args) => args[1] === "destroy")).toBe(false); + expect(warnings.join("\n")).toContain("Gateway 'nemoclaw' already removed or unreachable"); + }); + describe("user-data preservation under ~/.nemoclaw/", () => { function setupStateDir(): { tmpHome: string; stateDir: string } { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index a42496e5f20..f1ca6e8fb11 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -433,6 +433,48 @@ function runOptional( return false; } +const GATEWAY_ALREADY_ABSENT = + /gateway[^\n]*(?:does not exist|not found)|No (?:active )?gateway|No gateway metadata found/i; +const GATEWAY_REMOVE_UNSUPPORTED = + /unrecognized subcommand ['"]remove['"]|unknown command ['"]remove['"]/i; + +function removeGatewayRegistration(runtime: UninstallRuntime, gatewayLabel: string): boolean { + const removeResult = runtime.run("openshell", ["gateway", "remove", gatewayLabel], { + env: runtime.env, + }); + if (removeResult.status === 0) { + runtime.log(`Removed gateway registration '${gatewayLabel}'`); + return true; + } + + const removeOutput = `${removeResult.stdout}\n${removeResult.stderr}`; + if (GATEWAY_ALREADY_ABSENT.test(removeOutput)) { + runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + return true; + } + if (!GATEWAY_REMOVE_UNSUPPORTED.test(removeOutput)) { + runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + return false; + } + + // OpenShell builds before 0.0.44 exposed `gateway destroy` instead of the + // current `gateway remove` command. Only fall back when the modern verb is + // explicitly unsupported so a real removal failure is not hidden. + const destroyResult = runtime.run("openshell", ["gateway", "destroy", "-g", gatewayLabel], { + env: runtime.env, + }); + if (destroyResult.status === 0) { + runtime.log(`Destroyed legacy gateway '${gatewayLabel}'`); + return true; + } + if (GATEWAY_ALREADY_ABSENT.test(`${destroyResult.stdout}\n${destroyResult.stderr}`)) { + runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + return true; + } + runtime.warn(gatewayDestroySkipMessage(gatewayLabel)); + return false; +} + function stopHelperServices(paths: UninstallPaths, runtime: UninstallRuntime): void { const startServices = path.join(paths.repoRoot, "scripts", "start-services.sh"); if (runtime.existsSync(startServices)) @@ -855,13 +897,7 @@ function removeOpenShellResources( ) && removedSelectedResources; } removedSelectedResources = - runOptional( - runtime, - `Destroyed gateway '${gatewayLabel}'`, - "openshell", - ["gateway", "destroy", "-g", gatewayLabel], - { onSkip: gatewayDestroySkipMessage(gatewayLabel) }, - ) && removedSelectedResources; + removeGatewayRegistration(runtime, gatewayLabel) && removedSelectedResources; if (!removedSelectedResources) { runtime.warn("Selected gateway cleanup was incomplete; preserving its state for retry."); return false; @@ -889,13 +925,7 @@ function removeOpenShellResources( { onSkip: providerDeleteSkipMessage(provider) }, ); } - runOptional( - runtime, - `Destroyed gateway '${gatewayLabel}'`, - "openshell", - ["gateway", "destroy", "-g", gatewayLabel], - { onSkip: gatewayDestroySkipMessage(gatewayLabel) }, - ); + removeGatewayRegistration(runtime, gatewayLabel); return true; } From e66655cb3251e0a010b8c5732603129b37e07c33 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 14:42:17 -0700 Subject: [PATCH 23/28] test(uninstall): isolate gateway compatibility cases --- .../run-plan-gateway-segregation.test.ts | 64 +++++++++++++++++++ src/lib/actions/uninstall/run-plan.test.ts | 57 ----------------- 2 files changed, 64 insertions(+), 57 deletions(-) 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 e2566298dd6..23f07fd9a49 100644 --- a/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -21,6 +21,70 @@ afterEach(() => { }); describe("uninstall gateway-port segregation (#3053)", () => { + it("falls back to legacy gateway destroy only when gateway remove is unsupported", () => { + const calls: Array<{ args: string[]; command: string }> = []; + const responses = new Map([ + [ + "openshell gateway remove nemoclaw", + { status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" }, + ], + ]); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + rmSync: vi.fn(), + run: (command, args) => { + calls.push({ args, command }); + return responses.get([command, ...args].join(" ")) ?? ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(openshellCalls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); + }); + + it("does not hide a current gateway remove failure behind the legacy verb", () => { + const calls: Array<{ args: string[]; command: string }> = []; + const warnings: string[] = []; + const responses = new Map([ + ["openshell gateway remove nemoclaw", { status: 1, stdout: "", stderr: "permission denied" }], + ]); + const result = runUninstallPlan( + { assumeYes: true, deleteModels: false, keepOpenShell: true }, + { + commandExists: (command) => command !== "docker" && command !== "pgrep", + env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, + existsSync: () => false, + isTty: false, + rmSync: vi.fn(), + run: (command, args) => { + calls.push({ args, command }); + return responses.get([command, ...args].join(" ")) ?? ok(); + }, + runDocker: () => ok(""), + error: (line) => warnings.push(line), + }, + ); + + expect(result.exitCode).toBe(0); + const openshellCalls = calls + .filter(({ command }) => command === "openshell") + .map(({ args }) => args); + expect(openshellCalls).toContainEqual(["gateway", "remove", "nemoclaw"]); + expect(openshellCalls.some((args) => args[1] === "destroy")).toBe(false); + expect(warnings.join("\n")).toContain("Gateway 'nemoclaw' already removed or unreachable"); + }); + it("preserves the gateways/ subtree so uninstalling one environment leaves the others", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-gwpreserve-")); try { diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index b839d137b57..480b60a9477 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -966,63 +966,6 @@ describe("uninstall run plan", () => { ); }); - it("falls back to legacy gateway destroy only when gateway remove is unsupported", () => { - const calls: string[][] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: (command) => command !== "docker" && command !== "pgrep", - env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, - existsSync: () => false, - isTty: false, - rmSync: vi.fn(), - run: (command, args) => { - if (command === "openshell") calls.push(args); - if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { - return { status: 2, stdout: "", stderr: "unrecognized subcommand 'remove'" }; - } - if (args[0] === "-c") return ok("/fake/bin/tool\n"); - return ok(); - }, - runDocker: () => ok(""), - }, - ); - - expect(result.exitCode).toBe(0); - expect(calls).toContainEqual(["gateway", "remove", "nemoclaw"]); - expect(calls).toContainEqual(["gateway", "destroy", "-g", "nemoclaw"]); - }); - - it("does not hide a current gateway remove failure behind the legacy verb", () => { - const calls: string[][] = []; - const warnings: string[] = []; - const result = runUninstallPlan( - { assumeYes: true, deleteModels: false, keepOpenShell: true }, - { - commandExists: (command) => command !== "docker" && command !== "pgrep", - env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv, - existsSync: () => false, - isTty: false, - rmSync: vi.fn(), - run: (command, args) => { - if (command === "openshell") calls.push(args); - if (command === "openshell" && args[0] === "gateway" && args[1] === "remove") { - return { status: 1, stdout: "", stderr: "permission denied" }; - } - if (args[0] === "-c") return ok("/fake/bin/tool\n"); - return ok(); - }, - runDocker: () => ok(""), - error: (line) => warnings.push(line), - }, - ); - - expect(result.exitCode).toBe(0); - expect(calls).toContainEqual(["gateway", "remove", "nemoclaw"]); - expect(calls.some((args) => args[1] === "destroy")).toBe(false); - expect(warnings.join("\n")).toContain("Gateway 'nemoclaw' already removed or unreachable"); - }); - describe("user-data preservation under ~/.nemoclaw/", () => { function setupStateDir(): { tmpHome: string; stateDir: string } { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); From 7d00f98a3fce554da2985375a42095bd96a802a7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 15:07:19 -0700 Subject: [PATCH 24/28] docs(uninstall): clarify gateway selection Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/lifecycle.mdx | 11 ++++++++--- docs/reference/commands.mdx | 15 ++++++++++----- test/install-gateway-state-root.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/manage-sandboxes/lifecycle.mdx b/docs/manage-sandboxes/lifecycle.mdx index f3352ce687a..eacc050a022 100644 --- a/docs/manage-sandboxes/lifecycle.mdx +++ b/docs/manage-sandboxes/lifecycle.mdx @@ -444,11 +444,16 @@ $$nemoclaw uninstall | `--keep-openshell` | Leave OpenShell binaries installed. | | `--delete-models` | Also remove NemoClaw-pulled Ollama models. | | `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`). | -| `--gateway ` | Override the gateway name to remove (default: `nemoclaw`). | +| `--gateway ` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT`. | + +`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. -The uninstall command preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots written by snapshot and `backup-all` commands), `~/.nemoclaw/backups/` (workspace backups written by `scripts/backup-workspace.sh`), and `~/.nemoclaw/sandboxes.json` (the sandbox registry) by default. -Uninstall removes every other entry under `~/.nemoclaw/`. +For the default gateway, the uninstall command preserves `~/.nemoclaw/rebuild-backups/` (host-side snapshots written by snapshot and `backup-all` commands), `~/.nemoclaw/backups/` (workspace backups written by `scripts/backup-workspace.sh`), and `~/.nemoclaw/sandboxes.json` (the sandbox registry) by default. +A non-default gateway uses the corresponding entries under `~/.nemoclaw/gateways//`. +When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/`; otherwise it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. Interactive runs prompt before they remove the preserved entries; the default answer keeps them. For non-interactive runs (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell), pass `--destroy-user-data` (or set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1`) to acknowledge data loss and remove the preserved entries as well. `--yes` stays non-destructive by design. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dd9d9b50cd1..39491bbe938 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3031,15 +3031,20 @@ On Linux, uninstall removes `~/.local/state/nemoclaw`, which contains Docker-dri | `--keep-openshell` | Leave OpenShell binaries installed | | `--delete-models` | Also remove NemoClaw-pulled Ollama models | | `--destroy-user-data` | Also remove preserved user data (`rebuild-backups/`, `backups/`, `sandboxes.json`) | -| `--gateway ` | Override the gateway name to remove (default: `$$nemoclaw`) | +| `--gateway ` | Optional consistency check; must match the name derived from `NEMOCLAW_GATEWAY_PORT` | ```bash $$nemoclaw uninstall [--yes] [--keep-openshell] [--delete-models] [--destroy-user-data] [--gateway ] ``` +`NEMOCLAW_GATEWAY_PORT` selects the gateway instance and state root to uninstall. +Port `8080` selects `nemoclaw` and the shared `~/.nemoclaw/` root; a non-default port selects `nemoclaw-` and `~/.nemoclaw/gateways//`. +For example, `NEMOCLAW_GATEWAY_PORT=9123 $$nemoclaw uninstall` selects `nemoclaw-9123`. +The compatibility `--gateway` flag cannot select another instance: when present, it must match the name derived from `NEMOCLAW_GATEWAY_PORT`, or uninstall exits before cleanup. + ##### User-data preservation under `~/.nemoclaw/` -To avoid uninstall destroying host-side user data, uninstall preserves the following entries under `~/.nemoclaw/` by default: +To avoid uninstall destroying host-side user data, uninstall preserves the following entries in the selected gateway's state root by default. The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw/gateways//`. | Entry | What it holds | |---|---| @@ -3047,7 +3052,7 @@ To avoid uninstall destroying host-side user data, uninstall preserves the follo | `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes. Refer to [Backup and Restore](../manage-sandboxes/backup-restore). | | `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | -Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). +When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. `--yes` deliberately remains non-destructive for user data. It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. @@ -3059,9 +3064,9 @@ Decision matrix: | Context | Behaviour | |---|---| | 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 everything under `~/.nemoclaw/` (the previous full-removal behaviour). | +| Interactive TTY, user answers `y` | Removes the preserved entries in the selected gateway's state root; a single-gateway uninstall also removes the remaining shared state. | | 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 under `~/.nemoclaw/`. 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. 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. | The preserved entries survive uninstall as inert files on disk. diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index 874fe73b0fa..eb5cf47333a 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -223,4 +223,29 @@ save_usage_notice_acceptance_shell "test-version"`, fs.rmSync(home, { recursive: true, force: true }); } }); + + it("uses the gateway port as the uninstall selector and rejects a mismatched gateway flag", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-port-selector-")); + try { + const result = spawnSync( + process.execPath, + [CLI, "internal", "uninstall", "run-plan", "--yes", "--gateway", "nemoclaw-9000"], + { + encoding: "utf8", + env: { + ...process.env, + HOME: home, + NEMOCLAW_GATEWAY_PORT: "9123", + PATH: "", + }, + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(1); + expect(result.stderr).toContain("NEMOCLAW_GATEWAY_PORT=9123 selects 'nemoclaw-9123'"); + expect(fs.existsSync(path.join(home, ".nemoclaw"))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); }); From ada776f52efa136726dde258fc72a79f667ca9ca Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 20:26:55 -0700 Subject: [PATCH 25/28] fix(state): harden migration recovery paths --- docs/reference/commands.mdx | 6 ++- src/lib/actions/uninstall/run-plan.ts | 10 ++-- src/lib/cli/public-dispatch.ts | 15 +++++- src/lib/state/legacy-port-migration.test.ts | 54 ++++++++++++++++++++ src/lib/state/legacy-port-migration.ts | 44 +++++++++++++++- test/cli/dispatch-basics.test.ts | 37 ++++++++++++++ test/install-gateway-state-root.test.ts | 14 ++--- test/support/public-dispatch-test-harness.ts | 13 +++++ 8 files changed, 179 insertions(+), 14 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index a23dd4b0560..53d1d7d38d5 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3044,7 +3044,8 @@ The compatibility `--gateway` flag cannot select another instance: when present, ##### User-data preservation under `~/.nemoclaw/` -To avoid uninstall destroying host-side user data, uninstall preserves the following entries in the selected gateway's state root by default. The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw/gateways//`. +To avoid uninstall destroying host-side user data, uninstall preserves the following entries in the selected gateway's state root by default. +The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw/gateways//`. | Entry | What it holds | |---|---| @@ -3052,7 +3053,8 @@ To avoid uninstall destroying host-side user data, uninstall preserves the follo | `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes. Refer to [Backup and Restore](../manage-sandboxes/backup-restore). | | `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | -When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. +When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). +When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. `--yes` deliberately remains non-destructive for user data. It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries. diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index f1ca6e8fb11..cc642fe6845 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -1167,7 +1167,11 @@ function inspectOtherGatewayEnvironments( } } -function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): void { +function removeManagedSwap( + paths: UninstallPaths, + runtime: UninstallRuntime, + otherGatewayEnvironmentsRemain: boolean, +): void { if (!runtime.existsSync("/swapfile")) { runtime.log("No /swapfile found; skipping swap cleanup."); removePath(paths.managedSwapMarkerPath, runtime); @@ -1179,7 +1183,7 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo ); return; } - if (inspectOtherGatewayEnvironments(paths, runtime).otherGatewayEnvironmentsRemain) { + if (otherGatewayEnvironmentsRemain) { runtime.log( "Other NemoClaw gateway-port environments remain; keeping the host-shared /swapfile.", ); @@ -1370,7 +1374,7 @@ function executePlan( removeOllamaModels(options, runtime); } } else if (step.name === "State and binaries") { - removeManagedSwap(paths, runtime); + removeManagedSwap(paths, runtime, scopedToSelectedGateway); if (!scopedToSelectedGateway) { for (const pattern of paths.runtimeTempGlobs) removeGlob(pattern, runtime); if (options.keepOpenShell) runtime.log("Keeping OpenShell binaries as requested."); diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 66d0c18fcf2..2e1f9e81e41 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -41,6 +41,7 @@ import { const GLOBAL_COMMANDS = globalCommandTokens(); const NATIVE_OCLIF_NAMESPACES = new Set(["internal", "sandbox"]); +const MIGRATION_RECOVERY_SANDBOX_ACTIONS = new Set(["doctor", "recover"]); type RegistryModule = typeof import("../state/registry"); type RegistryRecoveryModule = typeof import("../registry-recovery-action"); @@ -111,6 +112,18 @@ function hasPublicSandboxHelpFlag(action: string, args: readonly string[]): bool return hasHelpFlag(argsBeforeSeparator(args)); } +function isMigrationRecoveryInvocation(argv: readonly string[]): boolean { + if (argv[0] === "internal") return true; + if (argv[0] === "sandbox") { + return MIGRATION_RECOVERY_SANDBOX_ACTIONS.has(argv[1] ?? ""); + } + return ( + argv.length > 1 && + !GLOBAL_COMMANDS.has(argv[0] ?? "") && + MIGRATION_RECOVERY_SANDBOX_ACTIONS.has(argv[1] ?? "") + ); +} + function findRegisteredSandboxName(tokens: string[]): string | null { const registered = new Set( registry() @@ -465,7 +478,7 @@ export async function dispatchCli(argv: string[] = process.argv.slice(2)): Promi argv[0] === "version" || argv[0] === "help" || argv[0] === "completion"; - if (!stateFreeInvocation) { + if (!stateFreeInvocation && !isMigrationRecoveryInvocation(argv)) { try { const migration = migrateLegacyPortState(); if (migration.migratedSandboxNames.length > 0 || migration.migratedSession) { diff --git a/src/lib/state/legacy-port-migration.test.ts b/src/lib/state/legacy-port-migration.test.ts index 158e6683848..313b17fae69 100644 --- a/src/lib/state/legacy-port-migration.test.ts +++ b/src/lib/state/legacy-port-migration.test.ts @@ -245,6 +245,60 @@ describe("legacy non-default gateway state migration", () => { expect(fs.existsSync(path.join(selected, "credentials.json"))).toBe(true); }); + it.each([ + 8080, 9123, + ])("removes only generated stale migration-intent directories for gateway port %i", (gatewayPort) => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const preparing = path.join(shared, ".gateway-state-migration.preparing.999999.1"); + const completed = path.join(shared, ".gateway-state-migration.completed.999999.2"); + const unrelated = path.join(shared, ".gateway-state-migration.preparing.not-a-pid.3"); + fs.mkdirSync(preparing, { recursive: true }); + fs.mkdirSync(completed, { recursive: true }); + fs.mkdirSync(unrelated, { recursive: true }); + + expect(migrateLegacyPortState({ home, gatewayPort })).toEqual({ + migratedSandboxNames: [], + migratedSession: false, + warnings: [], + }); + + expect(fs.existsSync(preparing)).toBe(false); + expect(fs.existsSync(completed)).toBe(false); + expect(fs.existsSync(unrelated)).toBe(true); + expect(fs.existsSync(path.join(shared, ".gateway-state-migration.lock"))).toBe(false); + }); + + it("refuses to follow a stale-intent symlink", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const controlled = path.join(home, "controlled"); + const candidate = path.join(shared, ".gateway-state-migration.completed.999999.4"); + fs.mkdirSync(shared, { recursive: true }); + fs.mkdirSync(controlled); + fs.writeFileSync(path.join(controlled, "sentinel"), "keep"); + fs.symlinkSync(controlled, candidate, "dir"); + + expect(() => migrateLegacyPortState({ home, gatewayPort: 9123 })).toThrow(/symbolic link/); + expect(fs.readFileSync(path.join(controlled, "sentinel"), "utf8")).toBe("keep"); + expect(fs.lstatSync(candidate).isSymbolicLink()).toBe(true); + }); + + it("does not sweep intent directories while another migration owns the lock", () => { + const home = makeHome(); + const shared = path.join(home, ".nemoclaw"); + const stale = path.join(shared, ".gateway-state-migration.preparing.999999.5"); + const lock = path.join(shared, ".gateway-state-migration.lock"); + fs.mkdirSync(stale, { recursive: true }); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, "owner"), String(process.pid)); + + expect(() => migrateLegacyPortState({ home, gatewayPort: 9123 })).toThrow( + /another state operation owns/, + ); + expect(fs.existsSync(stale)).toBe(true); + }); + it("does not modify the byte-compatible default gateway root", () => { const home = makeHome(); const registry = path.join(home, ".nemoclaw", "sandboxes.json"); diff --git a/src/lib/state/legacy-port-migration.ts b/src/lib/state/legacy-port-migration.ts index b431150567a..74608082ffc 100644 --- a/src/lib/state/legacy-port-migration.ts +++ b/src/lib/state/legacy-port-migration.ts @@ -24,6 +24,8 @@ const MIGRATION_INTENT_SELECTED_REGISTRY = "selected-registry.json"; const MIGRATION_INTENT_REMAINING_REGISTRY = "remaining-registry.json"; const MIGRATION_INTENT_VERSION = 1; const MIGRATION_LOCK_STALE_MS = 10_000; +const STALE_MIGRATION_INTENT_PATTERN = + /^\.gateway-state-migration\.(?:preparing|completed)\.[1-9][0-9]*\.[1-9][0-9]*$/; const MAX_MIGRATABLE_JSON_BYTES = 16 * 1024 * 1024; const LEGACY_BUNDLE_ENTRIES = [ "backups", @@ -483,6 +485,29 @@ function removeMigrationIntent(home: string, sharedRoot: string, intentDir: stri fsyncDirectory(sharedRoot); } +function staleMigrationIntentNames(home: string, sharedRoot: string): string[] { + const rootStat = lstatNoFollow(home, sharedRoot); + if (!rootStat) return []; + if (!rootStat.isDirectory()) throw migrationError(`${sharedRoot} is not a directory`); + return fs + .readdirSync(sharedRoot) + .filter((name) => STALE_MIGRATION_INTENT_PATTERN.test(name)) + .sort(); +} + +function removeStaleMigrationIntentDirectories(home: string, sharedRoot: string): void { + const staleNames = staleMigrationIntentNames(home, sharedRoot); + for (const name of staleNames) { + const candidate = path.join(sharedRoot, name); + const stat = lstatNoFollow(home, candidate); + if (!stat?.isDirectory()) { + throw migrationError(`${candidate} is not a directory`); + } + fs.rmSync(candidate, { recursive: true, force: true }); + } + if (staleNames.length > 0) fsyncDirectory(sharedRoot); +} + function applyMigrationIntent( home: string, sharedRoot: string, @@ -613,6 +638,7 @@ export function migrateLegacyPortState( const legacyRegistryFile = path.join(sharedRoot, "sandboxes.json"); const migrationLock = path.join(sharedRoot, MIGRATION_LOCK); const pendingBeforeLock = readMigrationIntent(home, sharedRoot); + const staleIntentDirectoriesExist = staleMigrationIntentNames(home, sharedRoot).length > 0; if (gatewayPort === DEFAULT_GATEWAY_PORT) { if (pendingBeforeLock) { @@ -630,6 +656,14 @@ export function migrateLegacyPortState( ); } } + if (staleIntentDirectoriesExist) { + const lock = acquireDirectoryLock(home, migrationLock); + try { + removeStaleMigrationIntentDirectories(home, sharedRoot); + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + } + } return result; } @@ -638,11 +672,19 @@ export function migrateLegacyPortState( const legacyRegistry = readGatewayRegistryFile(home, legacyRegistryFile); const legacySessionFile = path.join(sharedRoot, "onboard-session.json"); const legacySession = readJsonNoFollow(home, legacySessionFile); - if (!pendingBeforeLock && !legacyRegistry && legacySession === null) return result; + if ( + !pendingBeforeLock && + !legacyRegistry && + legacySession === null && + !staleIntentDirectoriesExist + ) { + return result; + } const lock = acquireDirectoryLock(home, migrationLock); const registryLocks: string[] = []; try { + removeStaleMigrationIntentDirectories(home, sharedRoot); registryLocks.push(acquireDirectoryLock(home, `${legacyRegistryFile}.lock`)); const pendingIntent = readMigrationIntent(home, sharedRoot); if (pendingIntent) { diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index 6f504b43d97..a626bb6518d 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -206,6 +206,43 @@ describe("CLI dispatch", () => { ); }); + it.each([ + ["internal uninstall", ["internal", "uninstall", "run-plan", "--yes"]], + ["native sandbox recovery", ["sandbox", "recover", "alpha"]], + ["public sandbox diagnostics", ["alpha", "doctor"]], + ] as const)("routes %s when legacy migration is broken", async (_label, argv) => { + await withDirectPublicDispatch( + async ({ dispatchCli, migrateLegacyPortState, runOclifArgv, runOclifCommandById }) => { + await dispatchCli([...argv]); + + expect(migrateLegacyPortState).not.toHaveBeenCalled(); + expect(runOclifArgv.mock.calls.length + runOclifCommandById.mock.calls.length).toBe(1); + }, + { migrationError: new Error("injected migration failure"), sandboxNames: ["alpha"] }, + ); + }); + + it("keeps ordinary stateful commands behind the migration gate", async () => { + await withDirectPublicDispatch( + async ({ + dispatchCli, + migrateLegacyPortState, + runOclifArgv, + runOclifCommandById, + stderr, + }) => { + await dispatchCli(["alpha", "status"]); + + expect(migrateLegacyPortState).toHaveBeenCalledTimes(1); + expect(runOclifArgv).not.toHaveBeenCalled(); + expect(runOclifCommandById).not.toHaveBeenCalled(); + expect(stderr.join("\n")).toContain("injected migration failure"); + expect(process.exitCode).toBe(1); + }, + { migrationError: new Error("injected migration failure"), sandboxNames: ["alpha"] }, + ); + }); + it("normalizes -h as a root-help alias", () => { expect( normalizeArgv(["-h"], { diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index eb5cf47333a..cad80949b9b 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -37,6 +37,12 @@ function readJson(filePath: string): Record { return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; } +function sanitizedParentEnv(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("NEMOCLAW_")), + ) as NodeJS.ProcessEnv; +} + function runInstallerFunctions( home: string, body: string, @@ -44,17 +50,11 @@ function runInstallerFunctions( const result = spawnSync("bash", ["-c", `source "${INSTALLER}" >/dev/null\n${body}`], { encoding: "utf8", env: { - ...process.env, + ...sanitizedParentEnv(), BASH_ENV: "", ENV: "", HOME: home, - NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT: "", - NEMOCLAW_DASHBOARD_PORT: "", NEMOCLAW_GATEWAY_PORT: "9123", - NEMOCLAW_OLLAMA_PORT: "", - NEMOCLAW_OLLAMA_PROXY_PORT: "", - NEMOCLAW_OPENROUTER_RUNTIME_ADAPTER_PORT: "", - NEMOCLAW_VLLM_PORT: "", }, }); return { output: `${result.stdout}${result.stderr}`, status: result.status }; diff --git a/test/support/public-dispatch-test-harness.ts b/test/support/public-dispatch-test-harness.ts index 36dd810eee9..c8a427334af 100644 --- a/test/support/public-dispatch-test-harness.ts +++ b/test/support/public-dispatch-test-harness.ts @@ -11,6 +11,7 @@ export type DirectPublicDispatchHarness = { getDefault: ReturnType; getSandbox: ReturnType; listSandboxes: ReturnType; + migrateLegacyPortState: ReturnType; recoverRegistryEntries: ReturnType; resetObservedCalls: () => void; runOclifArgv: ReturnType; @@ -27,6 +28,8 @@ type DirectPublicDispatchOptions = { pendingSandboxNames?: readonly string[]; /** Args the sandbox-connect stub treats as connect flags (default: none). */ connectFlags?: readonly string[]; + /** Error injected by the pre-dispatch legacy-state migration seam. */ + migrationError?: Error; }; const requireCache = require.cache as Record; @@ -58,12 +61,14 @@ export async function withDirectPublicDispatch( const oclifRunnerPath = require.resolve("../../src/lib/cli/oclif-runner.js"); const sandboxConnectPath = require.resolve("../../src/lib/actions/sandbox/connect.js"); const registryPath = require.resolve("../../src/lib/state/registry.js"); + const legacyPortMigrationPath = require.resolve("../../src/lib/state/legacy-port-migration.js"); const registryRecoveryPath = require.resolve("../../src/lib/registry-recovery-action.js"); const runnerPath = require.resolve("../../src/lib/runner.js"); const priorPublicDispatch = requireCache[publicDispatchPath]; const priorOclifRunner = requireCache[oclifRunnerPath]; const priorSandboxConnect = requireCache[sandboxConnectPath]; const priorRegistry = requireCache[registryPath]; + const priorLegacyPortMigration = requireCache[legacyPortMigrationPath]; const priorRegistryRecovery = requireCache[registryRecoveryPath]; const priorRunner = requireCache[runnerPath]; const priorDockerHost = process.env.DOCKER_HOST; @@ -96,6 +101,10 @@ export async function withDirectPublicDispatch( recoveredFromSession: false, recoveredFromGateway: 0, })); + const migrateLegacyPortState = vi.fn(() => { + if (options.migrationError) throw options.migrationError; + return { migratedSandboxNames: [], migratedSession: false, warnings: [] }; + }); const runOclifArgv = vi.fn(async () => undefined); const runOclifCommandById = vi.fn(async () => undefined); const stderr: string[] = []; @@ -112,12 +121,14 @@ export async function withDirectPublicDispatch( getDefault.mockClear(); getSandbox.mockClear(); listSandboxes.mockClear(); + migrateLegacyPortState.mockClear(); recoverRegistryEntries.mockClear(); runOclifArgv.mockClear(); runOclifCommandById.mockClear(); }; cacheModule(registryPath, { getDefault, getSandbox, listSandboxes }); + cacheModule(legacyPortMigrationPath, { migrateLegacyPortState }); cacheModule(registryRecoveryPath, { recoverRegistryEntries }); cacheModule(oclifRunnerPath, { runOclifArgv, runOclifCommandById }); const connectFlags = new Set(options.connectFlags ?? []); @@ -140,6 +151,7 @@ export async function withDirectPublicDispatch( getDefault, getSandbox, listSandboxes, + migrateLegacyPortState, recoverRegistryEntries, resetObservedCalls, runOclifArgv, @@ -155,6 +167,7 @@ export async function withDirectPublicDispatch( restoreCache(oclifRunnerPath, priorOclifRunner); restoreCache(sandboxConnectPath, priorSandboxConnect); restoreCache(registryPath, priorRegistry); + restoreCache(legacyPortMigrationPath, priorLegacyPortMigration); restoreCache(registryRecoveryPath, priorRegistryRecovery); restoreCache(runnerPath, priorRunner); if (priorDockerHost === undefined) { From 09682cc53db45600bb64085a07d76808f1bd0df2 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 13 Jul 2026 21:03:56 -0700 Subject: [PATCH 26/28] fix(uninstall): migrate legacy state before planning --- src/lib/cli/public-dispatch.ts | 6 +++++- test/cli/dispatch-basics.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 2e1f9e81e41..1a63238742e 100644 --- a/src/lib/cli/public-dispatch.ts +++ b/src/lib/cli/public-dispatch.ts @@ -113,7 +113,11 @@ function hasPublicSandboxHelpFlag(action: string, args: readonly string[]): bool } function isMigrationRecoveryInvocation(argv: readonly string[]): boolean { - if (argv[0] === "internal") return true; + if (argv[0] === "internal") { + const isStatefulUninstall = + argv[1] === "uninstall" && (argv[2] === "plan" || argv[2] === "run-plan"); + return !isStatefulUninstall; + } if (argv[0] === "sandbox") { return MIGRATION_RECOVERY_SANDBOX_ACTIONS.has(argv[1] ?? ""); } diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index a626bb6518d..4959e57ab00 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -207,7 +207,6 @@ describe("CLI dispatch", () => { }); it.each([ - ["internal uninstall", ["internal", "uninstall", "run-plan", "--yes"]], ["native sandbox recovery", ["sandbox", "recover", "alpha"]], ["public sandbox diagnostics", ["alpha", "doctor"]], ] as const)("routes %s when legacy migration is broken", async (_label, argv) => { @@ -222,6 +221,34 @@ describe("CLI dispatch", () => { ); }); + it.each([ + ["plan", ["internal", "uninstall", "plan", "--json"]], + ["run-plan", ["internal", "uninstall", "run-plan", "--yes"]], + ] as const)("migrates legacy port state before internal uninstall %s", async (_label, argv) => { + await withDirectPublicDispatch( + async ({ dispatchCli, migrateLegacyPortState, runOclifArgv }) => { + await dispatchCli([...argv]); + + expect(migrateLegacyPortState).toHaveBeenCalledTimes(1); + expect(runOclifArgv).toHaveBeenCalledTimes(1); + }, + ); + }); + + it("fails closed before internal uninstall when legacy migration is unsafe", async () => { + await withDirectPublicDispatch( + async ({ dispatchCli, migrateLegacyPortState, runOclifArgv, stderr }) => { + await dispatchCli(["internal", "uninstall", "run-plan", "--yes"]); + + expect(migrateLegacyPortState).toHaveBeenCalledTimes(1); + expect(runOclifArgv).not.toHaveBeenCalled(); + expect(stderr.join("\n")).toContain("injected migration failure"); + expect(process.exitCode).toBe(1); + }, + { migrationError: new Error("injected migration failure") }, + ); + }); + it("keeps ordinary stateful commands behind the migration gate", async () => { await withDirectPublicDispatch( async ({ From 1a6990ecb561f51ba67f1e4c43312180e6c02f3e Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 14 Jul 2026 11:41:36 +0000 Subject: [PATCH 27/28] docs: fix uninstall cleanup scope and split multi-sentence lines Signed-off-by: Tinson Lai --- docs/manage-sandboxes/run-sandboxes.mdx | 12 ++++++++++-- docs/manage-sandboxes/uninstall-nemoclaw.mdx | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/manage-sandboxes/run-sandboxes.mdx b/docs/manage-sandboxes/run-sandboxes.mdx index 6b48ef00eca..ca4cfd3968a 100644 --- a/docs/manage-sandboxes/run-sandboxes.mdx +++ b/docs/manage-sandboxes/run-sandboxes.mdx @@ -58,7 +58,11 @@ When the default API port is already held by another sandbox, `$$nemoclaw onboar If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location. When other ports remain, `$$nemoclaw uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. +On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. +Provider credentials remain in the OpenShell gateway store. +The default port keeps the shared `~/.nemoclaw/` location. +When other ports remain, `$$nemoclaw uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. Gateway and dashboard cleanup is scoped by sandbox name and port. A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--control-ui-port` does not tear down the first sandbox's gateway or dashboard forward. @@ -67,7 +71,11 @@ A later onboarding run that uses a different `NEMOCLAW_GATEWAY_PORT` or `--contr If you intentionally run separate OpenShell gateways on the same host, set a different `NEMOCLAW_GATEWAY_PORT` before each onboarding run. NemoClaw isolates the gateway name and local state by port so one port-specific gateway does not replace another. -A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. Provider credentials remain in the OpenShell gateway store. The default port keeps the shared `~/.nemoclaw/` location. When other ports remain, `nemo-deepagents uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. +A non-default `NEMOCLAW_GATEWAY_PORT` also gets its own host state root at `~/.nemoclaw/gateways//`, with a separate sandbox registry, snapshots, and legacy credential-migration files, so gateway-scoped state stays segregated while shared host-level files remain under `~/.nemoclaw/`. +On first use after upgrading, NemoClaw moves legacy rows and related state only when their recorded gateway identity matches the selected port; ambiguous state is left untouched with remediation. +Provider credentials remain in the OpenShell gateway store. +The default port keeps the shared `~/.nemoclaw/` location. +When other ports remain, `nemo-deepagents uninstall` removes only the selected gateway and keeps the shared CLI, services, images, providers, configuration, models, and swap. Gateway cleanup is scoped by sandbox name and port. diff --git a/docs/manage-sandboxes/uninstall-nemoclaw.mdx b/docs/manage-sandboxes/uninstall-nemoclaw.mdx index 8c4f7bc0cf3..51498c92dc1 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -32,7 +32,9 @@ Do not use `--gateway` to select another instance; when supplied for compatibili For the default gateway, the uninstall command preserves `~/.nemoclaw/rebuild-backups/`, `~/.nemoclaw/backups/`, and `~/.nemoclaw/sandboxes.json` by default. A non-default gateway uses the corresponding entries under `~/.nemoclaw/gateways//`. -When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/`; otherwise it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. +When no sibling gateways remain, uninstall also removes the shared CLI, services, images, providers, configuration, models, and swap. +When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state and preserves those shared host resources. +Either way, the preserved entries above stay unless you pass `--destroy-user-data`. Interactive runs prompt before they remove the preserved entries, and the default answer keeps them. For non-interactive runs using `--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY shell, pass `--destroy-user-data` or set `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA=1` to acknowledge data loss and remove the preserved entries. From 354feda2de40991ab552b55a23b8bd73dc9cb446 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 15 Jul 2026 04:31:01 +0000 Subject: [PATCH 28/28] docs: stop overstating final-gateway uninstall cleanup scope Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 164522e4508..294d34ea5ba 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3068,8 +3068,8 @@ The default gateway uses `~/.nemoclaw/`; a non-default gateway uses `~/.nemoclaw | `backups/` | Host-side workspace backups that `scripts/backup-workspace.sh` writes. Refer to [Transfer State Manually](../manage-sandboxes/state-and-backups/transfer-state-manually). | | `sandboxes.json` | Host-side sandbox registry. NemoClaw uses it to map sandbox names back to their persistence directories when you reinstall. | -When no sibling gateways remain, uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). -When sibling gateways remain, it removes only the selected gateway's resources and port-scoped state while preserving shared host resources. +When no sibling gateways remain, uninstall 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. `--yes` deliberately remains non-destructive for user data. It only acknowledges the global `Proceed?` confirmation prompt and still preserves the listed entries.