diff --git a/docs/manage-sandboxes/run-sandboxes.mdx b/docs/manage-sandboxes/run-sandboxes.mdx index 5e9d7586b3..ca4cfd3968 100644 --- a/docs/manage-sandboxes/run-sandboxes.mdx +++ b/docs/manage-sandboxes/run-sandboxes.mdx @@ -58,6 +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. 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. @@ -66,6 +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. 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 69354ff4d6..51498c92dc 100644 --- a/docs/manage-sandboxes/uninstall-nemoclaw.mdx +++ b/docs/manage-sandboxes/uninstall-nemoclaw.mdx @@ -23,11 +23,18 @@ $$nemoclaw uninstall | `--keep-openshell` | Leave OpenShell binaries installed. | | `--delete-models` | Also remove NemoClaw-pulled Ollama models. | | `--destroy-user-data` | Also remove preserved user data in `rebuild-backups/`, `backups/`, and `sandboxes.json`. | -| `--gateway ` | Override the gateway name to remove. The default is `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/`, `~/.nemoclaw/backups/`, and `~/.nemoclaw/sandboxes.json` by default. -Uninstall removes every other entry under `~/.nemoclaw/`. +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 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. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 1401afd46b..372e567671 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -307,7 +307,8 @@ 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, 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. | @@ -316,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. | @@ -338,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/docs/reference/commands.mdx b/docs/reference/commands.mdx index 39db3ce51b..9f8701c206 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3057,15 +3057,21 @@ 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 | |---|---| @@ -3073,7 +3079,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 [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. | -Uninstall removes every other entry under `~/.nemoclaw/` (gateway source, runtime state, the Ollama auth proxy PID file, etc.). +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. @@ -3085,9 +3092,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/scripts/install.sh b/scripts/install.sh index a0f3ed7e3d..a07ffb8df9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -193,6 +193,109 @@ 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 + 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)" || return 1 + if [ "$port" -eq 8080 ]; then + printf "%s/.nemoclaw" "$HOME" + else + printf "%s/.nemoclaw/gateways/%s" "$HOME" "$port" + 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)" || return 1 + 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 +346,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 +407,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"); @@ -317,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")" @@ -335,8 +441,14 @@ restore_onboard_forward_after_post_checks() { return 0 fi - state_dir="${HOME}/.nemoclaw/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 @@ -725,26 +837,43 @@ json_string_field() { } usage_notice_state_file() { - printf "%s/.nemoclaw/usage-notice.json" "${HOME}" + 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() { @@ -1703,8 +1832,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 +1849,43 @@ 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); // Keep this raw-registry predicate in sync with isRouteOnlySandboxReservation() // in src/lib/state/registry.ts. const sandboxes = entries.filter( @@ -1750,12 +1913,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() { @@ -1839,7 +2009,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() { @@ -1930,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 </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 } @@ -2339,7 +2529,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. @@ -2850,6 +3041,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 @@ -2897,9 +3092,10 @@ main() { step 3 "Onboarding" if [ -n "$_cli_runner" ]; then local _registered_sandbox_count="" - if [[ -f "${HOME}/.nemoclaw/sandboxes.json" ]] \ - && _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/src/commands/internal/uninstall/run-plan.ts b/src/commands/internal/uninstall/run-plan.ts index aad6acc563..d319246818 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/maintenance.test.ts b/src/lib/actions/maintenance.test.ts index 7d81b71cbb..830e66ad5f 100644 --- a/src/lib/actions/maintenance.test.ts +++ b/src/lib/actions/maintenance.test.ts @@ -53,7 +53,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(() => { @@ -75,6 +80,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 e1d8446022..2910f6d844 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"; import { backupStartedSandboxState, returnSandboxContainerToStopped, @@ -35,6 +39,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)`; } @@ -171,7 +179,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) { diff --git a/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts b/src/lib/actions/uninstall/openrouter-runtime-adapter-cleanup.ts index 53d4beb025..0c248b73c0 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 new file mode 100644 index 0000000000..23f07fd9a4 --- /dev/null +++ b/src/lib/actions/uninstall/run-plan-gateway-segregation.test.ts @@ -0,0 +1,659 @@ +// 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"; + +import { readGatewayRegistryFile } from "../../state/gateway-registry"; +import { migrateLegacyPortState } from "../../state/legacy-port-migration"; +import { type RunResult, runUninstallPlan } from "./run-plan"; + +function ok(stdout = ""): RunResult { + return { status: 0, stdout, stderr: "" }; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +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 { + 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"), + 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: (command) => command === "openshell", + 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 runCalls: 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: fs.rmSync, + run: (_command, args) => { + runCalls.push(args); + return ok(); + }, + runDocker: () => ok(""), + }, + ); + + expect(result.exitCode).toBe(0); + expect(logs).toContain( + "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"), + JSON.stringify({ defaultSandbox: null, sandboxes: {} }), + ); + 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 }); + } + }); + + 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("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; + 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", "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); + 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-preserved-registry.test.ts b/src/lib/actions/uninstall/run-plan-preserved-registry.test.ts index e4e4588606..37a4bed2aa 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 }; } diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index bc9885e7dc..480b60a947 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(); @@ -981,7 +977,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 +1296,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 +1319,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 282b60394a..cc642fe684 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -8,24 +8,34 @@ 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"; -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 { 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 { + 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"; @@ -109,6 +119,39 @@ 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; + } +} + +type SharedRegistrySiblingStatus = "none" | "present" | "uncertain"; + +function sharedRegistrySiblingStatus( + paths: UninstallPaths, + 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 @@ -156,6 +199,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[], @@ -277,10 +330,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 { @@ -289,23 +350,49 @@ 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, + 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"); - runtime.log(userDataDispositionLine(options, runtime)); + 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)`); runtime.log( @@ -331,11 +418,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. @@ -343,6 +430,49 @@ 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; +} + +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 { @@ -446,7 +576,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, @@ -475,6 +609,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. @@ -545,7 +684,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". @@ -563,6 +706,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."); @@ -603,10 +751,159 @@ 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 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, + 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 = + removeGatewayRegistration(runtime, 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. @@ -628,14 +925,8 @@ function removeOpenShellResources(options: UninstallRunOptions, runtime: Uninsta { onSkip: providerDeleteSkipMessage(provider) }, ); } - const gatewayLabel = options.gatewayName || "nemoclaw"; - runOptional( - runtime, - `Destroyed gateway '${gatewayLabel}'`, - "openshell", - ["gateway", "destroy", "-g", gatewayLabel], - { onSkip: gatewayDestroySkipMessage(gatewayLabel) }, - ); + removeGatewayRegistration(runtime, gatewayLabel); + return true; } function removeAliases(paths: UninstallPaths, runtime: UninstallRuntime): void { @@ -727,12 +1018,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`); @@ -793,9 +1094,87 @@ function removeOllamaModels(options: UninstallRunOptions, runtime: UninstallRunt } } -function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): void { +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)) { + try { + if (fs.readdirSync(sharedRoot).some((entry) => !SHARED_HOST_STATE_ENTRIES.has(entry))) { + 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 { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } + } + + const gatewaysDir = path.join(sharedRoot, GATEWAYS_SUBDIR); + if (!pathEntryExists(gatewaysDir, runtime)) { + return { + otherGatewayEnvironmentsRemain: false, + sharedRegistryMustBePreserved: false, + }; + } + try { + const gatewaysStat = fs.lstatSync(gatewaysDir); + 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 { + otherGatewayEnvironmentsRemain: true, + sharedRegistryMustBePreserved: false, + }; + } +} + +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); return; } if (!runtime.existsSync(paths.managedSwapMarkerPath)) { @@ -804,6 +1183,12 @@ function removeManagedSwap(paths: UninstallPaths, runtime: UninstallRuntime): vo ); return; } + if (otherGatewayEnvironmentsRemain) { + 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; @@ -817,8 +1202,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[] { @@ -887,66 +1274,154 @@ function executePlan( options: UninstallRunOptions, runtime: UninstallRuntime, preserveUnderStateDir: readonly string[], + scopedToSelectedGateway: boolean, + sharedRegistryMustBePreserved: 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 (!removePathExcept(paths.nemoclawStateDir, preserveUnderStateDir, runtime)) ok = false; - removePath(paths.gatewayLocalStateDir, runtime); - removePath(paths.openshellConfigDir, runtime); - removePath(paths.nemoclawConfigDir, 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."); + 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 (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, + [ + ...preserveUnderStateDir, + ...(selectedIsDefault + ? [GATEWAYS_SUBDIR, path.basename(paths.managedSwapMarkerPath)] + : []), + ...(scopedToSelectedGateway && selectedIsDefault ? ["source"] : []), + ], + runtime, + ) + ) + ok = false; + 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 }; @@ -964,9 +1439,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), }); @@ -978,11 +1454,42 @@ 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)) 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 gatewayInspection = inspectOtherGatewayEnvironments(paths, runtime); + const { otherGatewayEnvironmentsRemain: scopedToSelectedGateway } = gatewayInspection; + 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, + gatewayInspection.sharedRegistryMustBePreserved, + sandboxNames, + ); if (ok) { printBye(runtime); } else { diff --git a/src/lib/cli/public-dispatch.ts b/src/lib/cli/public-dispatch.ts index 0c4d3840d5..1a63238742 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, @@ -40,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"); @@ -110,6 +112,22 @@ function hasPublicSandboxHelpFlag(action: string, args: readonly string[]): bool return hasHelpFlag(argsBeforeSeparator(args)); } +function isMigrationRecoveryInvocation(argv: readonly string[]): boolean { + 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] ?? ""); + } + 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() @@ -456,6 +474,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 && !isMigrationRecoveryInvocation(argv)) { + 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/credentials/store.ts b/src/lib/credentials/store.ts index 2c22c85d70..5f4ee38d1b 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,9 +14,11 @@ 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"; +import { nemoclawStateRoot } from "../state/state-root"; const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); @@ -127,10 +129,10 @@ export function getCredsDir(): string { const home = resolveHomeDir(); if (_cachedHome !== home) { _cachedHome = home; - _credsDir = path.join(home, ".nemoclaw"); + _credsDir = nemoclawStateRoot(home, GATEWAY_PORT); _legacyCredsFile = null; } - return _credsDir || path.join(home, ".nemoclaw"); + return _credsDir || nemoclawStateRoot(home, GATEWAY_PORT); } /** diff --git a/src/lib/domain/uninstall/paths.ts b/src/lib/domain/uninstall/paths.ts index 3d4149430f..37ef50e095 100644 --- a/src/lib/domain/uninstall/paths.ts +++ b/src/lib/domain/uninstall/paths.ts @@ -3,6 +3,10 @@ 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"; export const NEMOCLAW_PROVIDERS = [ "nvidia-nim", @@ -38,6 +42,7 @@ export interface UninstallPaths { agentAliasShimPaths: Array<{ binName: string; path: string }>; nemoclawStateDir: string; gatewayLocalStateDir: string; + selectedGatewayLocalStateDir: string; openshellConfigDir: string; openshellInstallPaths: string[]; repoRoot: string; @@ -59,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"), @@ -68,8 +74,12 @@ export function defaultUninstallPaths(options: UninstallPathOptions): UninstallP binName, path: path.join(options.home, ".local", "bin", binName), })), - nemoclawStateDir: path.join(options.home, ".nemoclaw"), - gatewayLocalStateDir: path.join(options.home, ".local", "state", "nemoclaw"), + nemoclawStateDir: nemoclawStateRoot(options.home, GATEWAY_PORT), + 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/inference/local-adapter-lifecycle.test.ts b/src/lib/inference/local-adapter-lifecycle.test.ts index 527aceb20a..22b884a7f4 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); }); + + 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", () => { + 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 5c028dc1d0..66a9aef11e 100644 --- a/src/lib/inference/local-adapter-lifecycle.ts +++ b/src/lib/inference/local-adapter-lifecycle.ts @@ -1,14 +1,17 @@ // 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"; import os from "node:os"; 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; @@ -22,20 +25,21 @@ 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(), 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); } } @@ -43,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 { @@ -59,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/src/lib/inference/local.test.ts b/src/lib/inference/local.test.ts index ddb71e843d..8cb4b6e471 100644 --- a/src/lib/inference/local.test.ts +++ b/src/lib/inference/local.test.ts @@ -520,6 +520,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 1b5df50262..68b84ea27a 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -14,10 +14,11 @@ 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"; +import { nemoclawStateRoot } from "../state/state-root"; import { buildSubprocessEnv } from "../subprocess-env"; import { detectNvidiaPlatform } from "./nim"; import { @@ -239,14 +240,17 @@ 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; } function defaultLoadOllamaProxyToken(): string | null { - const tokenPath = nodePath.join(os.homedir(), ".nemoclaw", "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/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index 5b9ba55669..1686a406da 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import fs from "node:fs/promises"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import { withGatewayRouteMutationLock } from "../inference/gateway-route-mutation-lock"; import { @@ -287,6 +288,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: [ @@ -329,7 +378,9 @@ describe("getRegistryOccupiedDashboardPorts", () => { describe("dashboard port reservation lock", () => { it("serializes onboard and restore ownership across different gateways", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-dashboard-port-lock-")); + const stateDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "nemoclaw-dashboard-port-lock-"), + ); let releaseOnboard!: () => void; const onboardReleased = new Promise((resolve) => { releaseOnboard = resolve; @@ -380,12 +431,14 @@ describe("dashboard port reservation lock", () => { ]); } finally { releaseOnboard(); - await fs.rm(stateDir, { recursive: true, force: true }); + await fsPromises.rm(stateDir, { recursive: true, force: true }); } }); it("releases a failed reservation so the next allocator can proceed", async () => { - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-dashboard-port-lock-")); + const stateDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "nemoclaw-dashboard-port-lock-"), + ); const options = { stateDir, pollIntervalMs: 1, timeoutMs: 5_000 }; try { await assert.rejects( @@ -399,7 +452,7 @@ describe("dashboard port reservation lock", () => { "restore acquired", ); } finally { - await fs.rm(stateDir, { recursive: true, force: true }); + await fsPromises.rm(stateDir, { recursive: true, force: true }); } }); }); diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 56bc5c2635..3bf0c9f0db 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"; import { type McpLifecycleLockOptions, withMcpLifecycleLock } from "../state/mcp-lifecycle-lock"; // runner.ts is still CommonJS — use require so module shape matches. @@ -29,6 +32,7 @@ type RunCaptureFn = typeof import("../runner").runCapture; type SandboxRegistryEntry = { name: string; dashboardPort?: number | null; + scopeGatewayPort?: number; }; export type ListSandboxesFn = () => { sandboxes: SandboxRegistryEntry[] }; @@ -186,13 +190,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, @@ -209,33 +211,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/onboard/docker-gpu-patch-diagnostics.ts b/src/lib/onboard/docker-gpu-patch-diagnostics.ts index f92c6f6294..5cd0f4b467 100644 --- a/src/lib/onboard/docker-gpu-patch-diagnostics.ts +++ b/src/lib/onboard/docker-gpu-patch-diagnostics.ts @@ -6,6 +6,9 @@ import os from "node:os"; 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"; import { getDockerGpuPatchFailureContext } from "./docker-gpu-patch-recreate"; @@ -146,13 +149,14 @@ export function collectDockerGpuPatchDiagnostics( const logs = deps.dockerLogs ?? dockerLogs; const now = (deps.now ?? (() => new Date()))(); const dir = path.join( - home, - ".nemoclaw", + nemoclawStateRoot(home, GATEWAY_PORT), "onboard-failures", `${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/model-router.ts b/src/lib/onboard/model-router.ts index a40afe0cc6..11a32de3ab 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, @@ -13,8 +14,10 @@ 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"; import { buildSubprocessEnv } from "../subprocess-env"; import { hydrateCredentialEnv } from "./credential-env"; import { @@ -40,7 +43,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(os.homedir(), ".nemoclaw", "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 = { @@ -224,10 +230,12 @@ 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, 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/src/lib/onboard/port-state-writer-symlink.test.ts b/src/lib/onboard/port-state-writer-symlink.test.ts new file mode 100644 index 0000000000..01cc1f44e6 --- /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 c05df67492..87ed446fe4 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -5,6 +5,10 @@ import fs from "node:fs"; 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; 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,14 +157,15 @@ export function collectSandboxCreateFailureDiagnostics( const homeDir = options.homeDir ?? os.homedir(); const now = options.now ?? new Date(); const dir = path.join( - homeDir, - ".nemoclaw", + nemoclawStateRoot(homeDir, GATEWAY_PORT), "onboard-failures", `${timestampForPath(now)}-${sanitizePathPart(sandboxName)}`, ); 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 421c9b0dac..c44a3e665e 100644 --- a/src/lib/onboard/usage-notice.ts +++ b/src/lib/onboard/usage-notice.ts @@ -6,6 +6,9 @@ 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 { writeConfigFile } from "../state/config-io"; +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 +55,10 @@ 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(), GATEWAY_PORT), + "usage-notice.json", + ); } export function loadUsageNoticeConfig(): NoticeConfig { @@ -80,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/src/lib/share-command.test.ts b/src/lib/share-command.test.ts index 2f2a3b866a..4941659355 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 0e03abd969..0fd317bb9b 100644 --- a/src/lib/share-command.ts +++ b/src/lib/share-command.ts @@ -13,9 +13,11 @@ import { spawnSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; - -import { buildShareCommandDeps } from "./share-command-deps"; +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 { readonly lines: readonly string[]; @@ -61,7 +63,11 @@ 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(), GATEWAY_PORT), + "mounts", + sandboxName, + ); } /** @@ -124,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; @@ -140,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/src/lib/state/config-io.ts b/src/lib/state/config-io.ts index e0a4cae76c..1e5e5384f3 100644 --- a/src/lib/state/config-io.ts +++ b/src/lib/state/config-io.ts @@ -6,9 +6,10 @@ 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 { GATEWAY_PORT } from "../core/ports"; +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 +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(home, ".nemoclaw"); + 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 = path.join(home, ".nemoclaw"); + 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/gateway-registry.test.ts b/src/lib/state/gateway-registry.test.ts new file mode 100644 index 0000000000..5a0cc75728 --- /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 0000000000..30381520ff --- /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 0000000000..313b17fae6 --- /dev/null +++ b/src/lib/state/legacy-port-migration.test.ts @@ -0,0 +1,318 @@ +// 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"; + +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(() => { + vi.restoreAllMocks(); + 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("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("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"); + 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.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"); + 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 0000000000..74608082ff --- /dev/null +++ b/src/lib/state/legacy-port-migration.ts @@ -0,0 +1,806 @@ +// 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 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 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", + "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; +type LegacyBundleEntry = (typeof LEGACY_BUNDLE_ENTRIES)[number]; +const LEGACY_BUNDLE_ENTRY_SET: ReadonlySet = new Set(LEGACY_BUNDLE_ENTRIES); + +export interface LegacyPortMigrationResult { + migratedSandboxNames: string[]; + migratedSession: boolean; + 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}`); +} + +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 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); + 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); + fsyncDirectory(dir); + } 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 lstatNoFollow(home: string, target: string): fs.Stats | null { + assertGatewayStatePathSafe(home, target); + try { + 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 null; + throw error; + } +} + +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); + fsyncDirectory(path.dirname(source)); + fsyncDirectory(path.dirname(destination)); +} + +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[], +): string[] { + const existing: string[] = []; + for (const sandboxName of sandboxNames) { + if ( + preflightMovePath( + home, + path.join(sharedRoot, "rebuild-backups", sandboxName), + path.join(selectedRoot, "rebuild-backups", sandboxName), + ) + ) { + existing.push(sandboxName); + } + } + return existing; +} + +function migrateSandboxBackups( + home: string, + sharedRoot: string, + selectedRoot: string, + sandboxNames: readonly string[], +): void { + for (const sandboxName of sandboxNames) { + resumeMovePath( + home, + path.join(sharedRoot, "rebuild-backups", sandboxName), + path.join(selectedRoot, "rebuild-backups", sandboxName), + ); + } +} + +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 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, + 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 { + process.kill(pid, 0); + return true; + } catch (error) { + 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 migrationError(`could not acquire ${lock}`); +} + +/** + * 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: [], + }; + const sharedRoot = nemoclawStateRoot(home, DEFAULT_GATEWAY_PORT); + 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) { + 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", + ); + } + } + if (staleIntentDirectoriesExist) { + const lock = acquireDirectoryLock(home, migrationLock); + try { + removeStaleMigrationIntentDirectories(home, sharedRoot); + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + } + } + 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 ( + !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) { + 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(); + 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; + + 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 (lstatNoFollow(home, activeLock)) { + throw migrationError( + `legacy onboarding lock ${activeLock} is present; finish or stop that run first`, + ); + } + moveSession = preflightMovePath( + home, + legacySessionFile, + path.join(selectedRoot, "onboard-session.json"), + ); + } + const sandboxBackupNames = preflightSandboxBackups( + home, + sharedRoot, + selectedRoot, + selectedNames, + ); + const bundleEntries: LegacyBundleEntry[] = []; + for (const entry of entriesToMove) { + if (preflightMovePath(home, path.join(sharedRoot, entry), path.join(selectedRoot, entry))) { + bundleEntries.push(entry); + } + } + + registryLocks.push(acquireDirectoryLock(home, `${selectedRegistryFile}.lock`)); + const existingSelected = readGatewayRegistryFile(home, selectedRegistryFile); + const selectedRegistry = mergeSelectedRegistry( + currentLegacy, + selectedEntries, + existingSelected, + gatewayPort, + ); + 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, + ); + } finally { + for (const registryLock of registryLocks.reverse()) { + fs.rmSync(registryLock, { recursive: true, force: true }); + } + fs.rmSync(lock, { recursive: true, force: true }); + } +} diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index aded1da006..be7589bbdc 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"; @@ -42,10 +43,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", 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 e92b72bac4..ffd5d9d285 100644 --- a/src/lib/state/paths.ts +++ b/src/lib/state/paths.ts @@ -4,11 +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 path.join(homeDir, ".nemoclaw"); + return nemoclawStateRoot(homeDir, GATEWAY_PORT); } 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 0000000000..5fa1a39695 --- /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 8375b12b47..b758be734b 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, @@ -28,6 +29,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 +163,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", GATEWAY_PORT), + "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 0efdf74ffd..1e28939284 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, @@ -54,10 +55,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, GATEWAY_PORT), "rebuild-backups"); const MANIFEST_VERSION = 1; export const OPENCLAW_IMAGE_PLUGIN_PROVENANCE_RESTORE_ERROR = @@ -577,7 +579,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", GATEWAY_PORT), + "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 0000000000..1db2a369ca --- /dev/null +++ b/src/lib/state/state-root.test.ts @@ -0,0 +1,49 @@ +// 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"; + +import { DEFAULT_GATEWAY_PORT } from "../core/ports"; +import { GATEWAYS_SUBDIR, 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", () => { + 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 new file mode 100644 index 0000000000..b1b8ae8897 --- /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); +} diff --git a/test/cli/dispatch-basics.test.ts b/test/cli/dispatch-basics.test.ts index 6f504b43d9..4959e57ab0 100644 --- a/test/cli/dispatch-basics.test.ts +++ b/test/cli/dispatch-basics.test.ts @@ -206,6 +206,70 @@ describe("CLI dispatch", () => { ); }); + it.each([ + ["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.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 ({ + 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/credentials.test.ts b/test/credentials.test.ts index 4841f7be45..520c570e80 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"); diff --git a/test/e2e/live/concurrent-gateway-ports.test.ts b/test/e2e/live/concurrent-gateway-ports.test.ts index e5e3f39ac8..bc2db09f4a 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 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, @@ -325,7 +339,8 @@ 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", - "destroying sandbox B leaves sandbox A healthy on the default gateway", + "each port-scoped registry lists only the sandbox owned by that gateway", + "uninstalling gateway B removes only its scoped state and leaves gateway A plus the shared CLI healthy", ], gatewayA, gatewayB, @@ -382,6 +397,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,34 +431,65 @@ 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"], { - 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", @@ -451,8 +498,15 @@ 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), + gatewayBUninstalled: uninstallB.exitCode === 0 && scopedStateRemoved.exitCode === 0, + sandboxAPreservedAfterUninstallB: ["Ready", "Running"].includes(phaseAAfterUninstallB), + sharedCliPreserved: listAAfterUninstallB.exitCode === 0, }, }); }); diff --git a/test/fixtures/uninstall-prompt-pty-driver.ts b/test/fixtures/uninstall-prompt-pty-driver.ts index d94c3dddc8..25bf93c16e 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, diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts new file mode 100644 index 0000000000..cad80949b9 --- /dev/null +++ b/test/install-gateway-state-root.test.ts @@ -0,0 +1,251 @@ +// 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"); + +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)); +} + +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, +): { output: string; status: number | null } { + const result = spawnSync("bash", ["-c", `source "${INSTALLER}" >/dev/null\n${body}`], { + encoding: "utf8", + env: { + ...sanitizedParentEnv(), + 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.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( + 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); + setup(root, controlled); + + 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 { + 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, "agents", "list"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home, + NEMOCLAW_GATEWAY_PORT: "9123", + PATH: "", + }, + }); + + 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 }); + } + }); + + 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 }); + } + }); +}); diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 22567449ee..59969cb185 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", diff --git a/test/install-preexisting-sandbox-recovery.test.ts b/test/install-preexisting-sandbox-recovery.test.ts index 70b53edef4..052217f375 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.", + ); + }); }); diff --git a/test/mcp-lifecycle-lock.test.ts b/test/mcp-lifecycle-lock.test.ts index 1a2639b547..79599b94ed 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/onboard-model-router.test.ts b/test/onboard-model-router.test.ts index decbac6303..d4c329deda 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,131 @@ 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.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 }); diff --git a/test/repro-2666-silent-list-status.test.ts b/test/repro-2666-silent-list-status.test.ts index 5a8a0251f0..c417d2dad2 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,31 +207,17 @@ 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(() => { 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, @@ -242,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 { @@ -259,6 +248,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, model = "test-model"): void { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + "my-assist": { + name: "my-assist", + 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); @@ -280,6 +289,42 @@ 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; + 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), + }); + const combined = `${stdout}\n${stderr}`; + + expect(code).toBe(0); + expect(combined).toContain("my-assist"); + expect(combined).toContain("selected-port-model"); + expect(combined).not.toContain("default-root-model"); + }); + + 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), @@ -379,6 +424,8 @@ 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 { // Fake docker: info OK, ps shows nothing running, ps -a shows the diff --git a/test/share-command-writable.test.ts b/test/share-command-writable.test.ts index 0c1f42a114..171e6f0e1d 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 }); + } + }); +}); diff --git a/test/support/public-dispatch-test-harness.ts b/test/support/public-dispatch-test-harness.ts index 36dd810eee..c8a427334a 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) { diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 40825d4ab1..c85860eb33 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 {