Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ At any prompt, press Enter to accept the default shown in `[brackets]`, type `ba
If registered sandboxes already exist, the installer prepares the current NemoClaw CLI without replacing OpenShell, then requires a fresh backup of every registered sandbox before it changes the gateway.
After the host upgrade, it runs `nemoclaw upgrade-sandboxes --auto` to rebuild stale sandboxes and restore validated backups for registered sandboxes that are not Ready.
Successful recovery completes the existing-sandbox upgrade and skips generic onboarding, so the installer does not create an extra sandbox or ask for a new provider credential.
If the recovery pass exits 0 but a recorded sandbox is not found on its own recorded gateway (for example after `nemoclaw uninstall` removed the gateway and Docker image while preserving `sandboxes.json`), the installer finishes with `Installation completed with warnings` and remediation guidance instead of claiming the sandbox was recovered.
For pre-fingerprint OpenClaw and Hermes registry entries, the installer asks you to confirm that every listed sandbox used a NemoClaw-managed image before it permits recovery onto the current managed image.
In non-interactive runs, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the exact JSON array of names printed by the installer, such as `["my-assistant","preserve-hermes"]`, only after you verify every named sandbox used a managed image.
Legacy managed-image confirmation never overrides recorded custom-image evidence.
Expand Down
2 changes: 2 additions & 0 deletions docs/manage-sandboxes/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ In a non-interactive run, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the
Legacy managed-image confirmation never overrides recorded custom-image evidence.
A custom OpenClaw sandbox can be recovered only when the selected validated backup independently carries complete authoritative image-plugin provenance; otherwise recovery stops before deletion.
The installer attempts every eligible recovery, exits with a nonzero status if any recovery fails, and skips generic onboarding after successful recovery.
A third outcome exists: when a recorded sandbox is not observed in any phase on its own recorded gateway (typically because a prior uninstall removed the gateway and Docker image while preserving `sandboxes.json`), the recovery pass exits 0 but reports the sandbox as not found rather than recovered, and the installer finishes with `Installation completed with warnings` plus remediation guidance (`$$nemoclaw <name> destroy`, then `$$nemoclaw onboard`) instead of claiming success.
For manual upgrade flows, create a snapshot first and then run the update or rebuild command you need:

```bash
Expand Down Expand Up @@ -421,6 +422,7 @@ For non-interactive runs (`--yes`, `NEMOCLAW_NON_INTERACTIVE=1`, or a non-TTY sh
`--yes` stays non-destructive by design.
It only acknowledges the global confirmation prompt and never purges preserved user data on its own.
Full purge always requires an explicit `--destroy-user-data` or the matching env var, so existing automation using `--yes` retains its safe behaviour.
Preserving `sandboxes.json` does not preserve the gateway registration, provider registrations, or Docker image its recorded sandboxes depend on; uninstall warns that those records cannot be recovered automatically on reinstall, and the remediation is `$$nemoclaw <name> destroy` followed by `$$nemoclaw onboard`.
For a full host-side file reference, see [Host Files and State](../reference/host-files-and-state).
Refer to the [Commands reference](../reference/commands#$$nemoclaw-uninstall) for the full preservation contract.
</Note>
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2412,8 +2412,10 @@ NemoClaw resolves the digest of `ghcr.io/nvidia/nemoclaw/sandbox-base:latest` fr
Sandboxes that match the current digest are left alone.
NemoClaw also checks the build fingerprint recorded on each managed sandbox image.
A sandbox needs upgrade when its agent version is stale, when its recorded NemoClaw image fingerprint differs from the running CLI, or both.
When the target version is older than the recorded one (for example after reinstalling with an older `NEMOCLAW_INSTALL_TAG`), the stale listing marks the change with a `(downgrade)` suffix instead of framing it as a routine upgrade.
Custom Dockerfile sandboxes are not classified by image drift because rebuilding them onto the default image would drop the custom image.
Legacy sandboxes without a recorded fingerprint opt into this check after their next rebuild.
A recorded sandbox that is not observed in any phase on its own recorded gateway is reported as not found there, with remediation guidance — this typically means its gateway registration or Docker image was removed (for example by `$$nemoclaw uninstall`, which preserves `sandboxes.json` but removes both).

```bash
$$nemoclaw upgrade-sandboxes [--check] [--auto] [--yes|-y]
Expand Down Expand Up @@ -2965,6 +2967,11 @@ Decision matrix:
The preserved entries survive uninstall as inert files on disk.
Reinstall NemoClaw and re-onboard the sandbox before `$$nemoclaw <name> snapshot restore` can use them.

Preserving `sandboxes.json` does not make the recorded sandboxes recoverable on their own: uninstall removes the gateway registration, provider registrations, and Docker image those records depend on.
Uninstall warns about this at preserve time.
After reinstalling, the installer reports such records as not found on their recorded gateway instead of claiming they were recovered; run `$$nemoclaw <name> destroy` to clear a stranded record, then `$$nemoclaw onboard` to rebuild it.
Pass `--destroy-user-data` at uninstall time if you prefer to purge the registry along with its dependencies.

#### `$$nemoclaw uninstall` vs. the hosted `uninstall.sh`

Both forms execute the same `uninstall.sh` with the same flags, but differ in where the script comes from and how much they trust the network.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/host-files-and-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ If you see `registry.json` in older tests, notes, or discussions, treat it as le

`$$nemoclaw uninstall --yes` removes active NemoClaw runtime resources but preserves the user data needed for recovery by default.
Preserved entries include `rebuild-backups/`, `backups/`, and `sandboxes.json`.
Preserved `sandboxes.json` records are not automatically recoverable after reinstall, because uninstall removes the gateway registration, provider registrations, and Docker image they reference; uninstall warns about this at preserve time, and a later reinstall reports such records as not found on their recorded gateway with `$$nemoclaw <name> destroy` / `$$nemoclaw onboard` remediation.
Interactive uninstall prompts before removing preserved state.
For non-interactive runs, pass `--destroy-user-data` only when you accept losing local registry metadata and backups.

Expand Down
71 changes: 66 additions & 5 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -536,23 +536,41 @@ print_done() {
# #5735: do not claim a clean install when the automatic upgrade of a
# pre-existing sandbox failed (it may have been destroyed before its recreate
# failed). Surface an explicit incomplete/recovery status instead.
# #6520: same when recovery exited 0 but recorded sandboxes were not found
# on their own recorded gateway — they were not recovered, so the install is
# not clean either.
if [[ "${_UPGRADE_SANDBOXES_FAILED:-false}" == true ]]; then
warn "=== Installation completed with warnings ==="
elif [[ "${_PREEXISTING_SANDBOX_ORPHANED:-false}" == true ]]; then
warn "=== Installation completed with warnings ==="
else
info "=== Installation complete ==="
fi
printf "\n"
printf " ${C_GREEN}${C_BOLD}%s${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$_CLI_DISPLAY" "$elapsed"
printf "\n"
if [[ "${_PREEXISTING_SANDBOX_RECOVERY_RAN:-false}" == true ]]; then
printf " ${C_GREEN}Existing sandboxes were recovered and upgraded.${C_RESET}\n"
if [[ "${_PREEXISTING_SANDBOX_ORPHANED:-false}" == true ]]; then
# #6520: recovery exited 0 but recorded sandboxes were not found on
# their own recorded gateway; do not report them as recovered, and give
# a concrete remediation path instead.
printf " ${C_YELLOW}Some recorded sandboxes were not found on their recorded gateway and were not recovered.${C_RESET}\n"
printf " ${C_YELLOW}Their gateway registration or Docker image may have been removed (see the recovery notes above).${C_RESET}\n"
printf " ${C_DIM}Clear a stranded sandbox with '%s <name> destroy', then rebuild it with '%s onboard'.${C_RESET}\n" "$_CLI_BIN" "$_CLI_BIN"
else
printf " ${C_GREEN}Existing sandboxes were recovered and upgraded.${C_RESET}\n"
fi
if [[ "$_needs_cli_refresh" == true ]]; then
printf " ${C_YELLOW}%s installed, but this shell needs PATH refresh before '%s' will run.${C_RESET}\n" "$_CLI_DISPLAY" "$_CLI_BIN"
printf "\n"
printf " ${C_GREEN}For this terminal:${C_RESET}\n"
print_cli_path_refresh_actions
fi
printf " ${C_DIM}No new sandbox onboarding was needed.${C_RESET}\n"
if [[ "${_PREEXISTING_SANDBOX_ORPHANED:-false}" == true ]]; then
printf " ${C_DIM}Generic onboarding was skipped because recorded sandboxes exist.${C_RESET}\n"
else
printf " ${C_DIM}No new sandbox onboarding was needed.${C_RESET}\n"
fi
elif [[ "$ONBOARD_RAN" == true ]]; then
local agent_name
agent_name="$(resolve_onboarded_agent)"
Expand Down Expand Up @@ -925,6 +943,11 @@ ONBOARD_RAN=false
_CLI_PATH=""
_PREEXISTING_SANDBOX_COUNT=0
_PREEXISTING_SANDBOX_RECOVERY_RAN=false
# #6520: set when the automatic recovery pass exited 0 but skipped recorded
# sandboxes it could not observe on the selected gateway (e.g. their gateway
# and Docker image were removed by a prior uninstall while sandboxes.json was
# preserved). The final summary must not claim those sandboxes were recovered.
_PREEXISTING_SANDBOX_ORPHANED=false
_LEGACY_MANAGED_RECOVERY_NAMES_JSON="[]"
# #5735: set when automatic recovery/upgrade of pre-existing sandboxes
# reported a failure. A failed/destructive rebuild must not be reported as a
Expand Down Expand Up @@ -2262,12 +2285,45 @@ recover_preexisting_sandboxes_before_onboard() {
# pre-upgrade backup signal is present, the CLI also recovers registered
# non-Ready sandboxes from their validated latest backup. It attempts every
# eligible sandbox before returning non-zero for any failure.
if NEMOCLAW_CONFIRMED_LEGACY_MANAGED_SANDBOXES="${_LEGACY_MANAGED_RECOVERY_NAMES_JSON:-[]}" \
"$cli_runner" upgrade-sandboxes --auto 2>&1; then
#
# #6520: mirror the CLI output into a temp log (while still streaming it) so
# the installer can tell "recovered" apart from "exited 0 but recorded
# sandboxes are unrecoverable" — e.g. after `nemoclaw uninstall` removed the
# gateway and Docker image a preserved sandboxes.json still references. The
# CLI emits a dedicated orphan marker only for sandboxes absent from their
# own recorded gateway (never for sandboxes bound to another live gateway or
# ones that reconnect mid-run); keep the grep in sync with the "recorded
# sandbox(es) were not found on their recorded gateway" line in
# src/lib/actions/upgrade-sandboxes.ts.
local recovery_log=""
recovery_log="$(mktemp "${TMPDIR:-/tmp}/nemoclaw-recovery-XXXXXX" 2>/dev/null)" || recovery_log=""
local recovery_status=0
if [ -n "$recovery_log" ]; then
_cleanup_files+=("$recovery_log")
if NEMOCLAW_CONFIRMED_LEGACY_MANAGED_SANDBOXES="${_LEGACY_MANAGED_RECOVERY_NAMES_JSON:-[]}" \
"$cli_runner" upgrade-sandboxes --auto 2>&1 | tee "$recovery_log"; then
recovery_status=0
else
# pipefail: take the CLI's own status, not tee's — a log-write failure
# (e.g. ENOSPC on TMPDIR) must not convert a successful recovery into
# the #5735 failure path.
recovery_status=${PIPESTATUS[0]}
fi
else
NEMOCLAW_CONFIRMED_LEGACY_MANAGED_SANDBOXES="${_LEGACY_MANAGED_RECOVERY_NAMES_JSON:-[]}" \
"$cli_runner" upgrade-sandboxes --auto 2>&1 || recovery_status=$?
fi
if [ "$recovery_status" -eq 0 ]; then
_PREEXISTING_SANDBOX_RECOVERY_RAN=true
if [ -n "$recovery_log" ] \
&& grep -Fq "recorded sandbox(es) were not found on their recorded gateway" "$recovery_log"; then
_PREEXISTING_SANDBOX_ORPHANED=true
fi
rm -f "$recovery_log" 2>/dev/null || true
return 0
fi

rm -f "$recovery_log" 2>/dev/null || true
_UPGRADE_SANDBOXES_FAILED=true
warn "One or more existing sandboxes could not be recovered automatically."
warn "Generic onboarding will not run; review the affected sandbox and preserved backup diagnostics above."
Expand Down Expand Up @@ -2858,7 +2914,12 @@ main() {
return 1
fi
if [[ "${_PREEXISTING_SANDBOX_RECOVERY_RAN:-false}" == true ]]; then
info "Existing sandboxes recovered; skipping generic onboarding."
if [[ "${_PREEXISTING_SANDBOX_ORPHANED:-false}" == true ]]; then
# #6520: do not claim recovery when recorded sandboxes are stranded.
warn "Some recorded sandboxes could not be recovered; skipping generic onboarding."
else
info "Existing sandboxes recovered; skipping generic onboarding."
fi
else
run_onboard || error "Onboarding did not complete successfully."
ONBOARD_RAN=true
Expand Down
154 changes: 154 additions & 0 deletions src/lib/actions/uninstall/run-plan-preserved-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it, vi } from "vitest";

import { type RunResult, runUninstallPlan, type UninstallRunDeps } from "./run-plan";

function ok(stdout = ""): RunResult {
return { status: 0, stdout, stderr: "" };
}

function notFound(): RunResult {
return { status: 1, stdout: "", stderr: "" };
}

function setupStateDir(): { tmpHome: string; stateDir: string } {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-registry-"));
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"), "[]");
return { tmpHome, stateDir };
}

function preserveCaseDeps(
tmpHome: string,
logs: string[],
warnings: string[],
opts: { envOverrides?: Record<string, string> } = {},
): UninstallRunDeps {
return {
commandExists: () => false,
env: {
HOME: tmpHome,
NEMOCLAW_NON_INTERACTIVE: "",
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
...(opts.envOverrides ?? {}),
} as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: (target: string) => target.startsWith(tmpHome) && fs.existsSync(target),
isTty: false,
log: (line) => logs.push(line),
run: vi.fn(() => ok()),
runDocker: () => ok(""),
};
}

describe("uninstall messaging for a preserved-but-orphaned sandbox registry (#6520)", () => {
it("uses the 'already removed' wording for provider and sandbox delete no-ops", () => {
// Same defect family as the gateway wording fix (#3456 sub-bug 4): when
// `openshell provider delete <name>` or `openshell sandbox delete --all`
// no-ops (target already gone), `Deleted provider 'X' skipped` reads as if
// the deletion both happened and was skipped.
const warnings: string[] = [];
const logs: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: (command) => command !== "docker" && command !== "pgrep",
env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: () => false,
isTty: false,
log: (line) => logs.push(line),
rmSync: vi.fn(),
run: (command, args) => {
if (command === "openshell") return notFound();
if (args[0] === "-c") return ok("/fake/bin/tool\n");
return ok();
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
runDocker: () => ok(""),
},
);

expect(result.exitCode).toBe(0);
const combined = `${warnings.join("\n")}\n${logs.join("\n")}`;
expect(warnings.join("\n")).toContain("Provider 'nvidia-nim' already removed or unreachable");
expect(warnings.join("\n")).toContain("OpenShell sandboxes already removed or unreachable");
expect(combined).not.toContain("Deleted provider 'nvidia-nim' skipped");
expect(combined).not.toContain("Deleted all OpenShell sandboxes skipped");
});

it("warns that preserved sandboxes.json cannot be auto-recovered after uninstall removes its dependencies", () => {
// Uninstall keeps sandboxes.json but removes the gateway, provider
// registrations, and Docker image its recorded sandboxes depend on. Say
// so at the moment the preserve choice is made, with a remediation path,
// instead of letting a later reinstall report false success.
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
preserveCaseDeps(tmpHome, logs, warnings),
);

expect(result.exitCode).toBe(0);
const joined = warnings.join("\n");
expect(joined).toContain("sandboxes.json");
expect(joined).toContain("cannot be recovered automatically");
expect(joined).toContain("--destroy-user-data");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("warns on the interactive keep path when the purge prompt is declined", () => {
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
// First reply confirms the uninstall itself; the empty second reply
// declines the purge prompt, keeping user data.
const replies = ["yes", ""];
const result = runUninstallPlan(
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
...preserveCaseDeps(tmpHome, logs, warnings),
isTty: true,
readLine: () => replies.shift() ?? null,
},
);

expect(result.exitCode).toBe(0);
expect(logs).toContain("Keeping user data.");
expect(warnings.join("\n")).toContain("cannot be recovered automatically");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});

it("does not warn about unrecoverable sandboxes when user data is purged", () => {
const { tmpHome } = setupStateDir();
try {
const logs: string[] = [];
const warnings: string[] = [];
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
preserveCaseDeps(tmpHome, logs, warnings, {
envOverrides: { NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "1" },
}),
);

expect(result.exitCode).toBe(0);
expect(warnings.join("\n")).not.toContain("cannot be recovered automatically");
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
});
Loading
Loading