Skip to content

fix(onboard): bind gateway name, state dir, and marker per gateway port (#4422) - #4645

Merged
cv merged 1 commit into
NVIDIA:mainfrom
yimoj:fix/4422-support-per-sandbox-gateway-port
Jun 2, 2026
Merged

fix(onboard): bind gateway name, state dir, and marker per gateway port (#4422)#4645
cv merged 1 commit into
NVIDIA:mainfrom
yimoj:fix/4422-support-per-sandbox-gateway-port

Conversation

@yimoj

@yimoj yimoj commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

A second sandbox onboarded with NEMOCLAW_GATEWAY_PORT recreated the process-global nemoclaw gateway and reused the singleton Docker-driver state directory, so creating it tore down the first sandbox's gateway and overwrote its runtime marker. This binds the gateway name, Docker-driver state dir (pid file + runtime marker), and compatibility container per gateway port so the second onboard no longer disrupts the first.

Related Issue

Fixes #4422.

Supersedes the runtime behavior left as a no-op in the groundwork PR #4598 (its resolver still returned the singleton name for every port).

Changes

  • Add src/lib/onboard/gateway-binding.ts — a per-port resolver. The default gateway port keeps the bare nemoclaw name, openshell-docker-gateway state dir, and nemoclaw-openshell-gateway compat container verbatim (backward compatible); a non-default port yields nemoclaw-<port>, openshell-docker-gateway-<port>, and nemoclaw-openshell-gateway-<port>.
  • onboard.ts: resolve GATEWAY_NAME, the Docker-driver state dir, and the compat container name from GATEWAY_PORT; bind the gateway health/reuse classifiers to the resolved name.
  • state/gateway.ts: hasStaleGateway / isGatewayHealthy / getGatewayReuseState / shouldSelectNamedGatewayForReuse accept an optional gateway name (default preserves the singleton). hasStaleGateway now matches the reported gateway name exactly instead of by substring.
  • gateway-reuse.ts and the gateway-drift.ts cluster-active probe forward the resolved gateway name to the classifiers.
  • state/registry.ts: persist gatewayName / gatewayPort per sandbox.
  • Tests: distinct runtime markers for two gateway ports (second onboard does not overwrite/invalidate the first), per-port compat container naming, port-aware health classification, and registry persistence for two ports.

Follow-up (out of scope here)

Threading the persisted gatewayName into every post-create read path (status/connect/destroy/doctor/snapshot) and anchoring the legacy openshell-cluster-* volume-prefix cleanup remain for a follow-up; they affect the second sandbox's own later lifecycle under the legacy cluster driver, not the create-time teardown this PR fixes.

Type of Change

  • Code change (feature, bug fix, or refactor)

Verification

  • npm test passes (targeted gateway/registry/reuse/drift suites green; full-suite failures on this host are environmental — unbuilt plugin dist, real-process timeouts under load, and a pre-existing chmod-ownership test that also fails on main)
  • Tests added or updated for new or changed behavior
  • No secrets, API keys, or credentials committed
  • npm run typecheck:cli passes

Reproduced end-to-end with the worktree build: a second onboard on port 8081 overwrote the first sandbox's Docker-driver runtime marker (endpoint→8081, pid changed → first gateway drifts) before the fix; after the fix the two markers live in distinct per-port state dirs and the first sandbox's marker is preserved with no drift.


Signed-off-by: Yimo Jiang yimoj@nvidia.com

Summary by CodeRabbit

  • New Features

    • Multi-port gateway support so isolated gateways can run concurrently on different ports.
    • Sandbox metadata now records gateway name and port.
    • Default gateway port standardized to 8080.
  • Bug Fixes

    • Docker compatibility container naming and runtime state isolated per gateway port to prevent cross-sandbox conflicts.
    • Health and reuse logic now evaluates gateway identity per port.
  • Tests

    • Added tests for per-port gateway behavior and state isolation.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 4a06da03-1c96-4a8d-be53-669dba9bd223

📥 Commits

Reviewing files that changed from the base of the PR and between 0125eae and 7e47e05.

📒 Files selected for processing (11)
  • src/lib/adapters/openshell/gateway-drift.ts
  • src/lib/core/ports.ts
  • src/lib/onboard.ts
  • src/lib/onboard/docker-driver-gateway-launch.ts
  • src/lib/onboard/gateway-binding.test.ts
  • src/lib/onboard/gateway-binding.ts
  • src/lib/onboard/gateway-reuse.ts
  • src/lib/state/gateway.ts
  • src/lib/state/registry.ts
  • test/gateway-state.test.ts
  • test/registry.test.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/lib/core/ports.ts
  • src/lib/state/registry.ts
  • src/lib/onboard/docker-driver-gateway-launch.ts
  • test/gateway-state.test.ts
  • test/registry.test.ts
  • src/lib/onboard/gateway-reuse.ts
  • src/lib/state/gateway.ts
  • src/lib/onboard/gateway-binding.ts
  • src/lib/onboard.ts
  • src/lib/onboard/gateway-binding.test.ts

📝 Walkthrough

Walkthrough

This PR makes OpenShell gateway identifiers, health checks, state dirs, Docker compat container names, and sandbox registry entries port-aware so multiple NemoClaw sandboxes can coexist on different gateway ports while keeping the default-port behavior unchanged.

Changes

Per-port OpenShell gateway isolation

Layer / File(s) Summary
Gateway binding resolution infrastructure
src/lib/core/ports.ts, src/lib/onboard/gateway-binding.ts, src/lib/onboard/gateway-binding.test.ts
Adds DEFAULT_GATEWAY_PORT and port-aware resolver functions (resolveGatewayName, resolveGatewayStateDirName, resolveGatewayCompatContainerName) plus createGatewayNameBoundClassifiers; tests validate default vs suffixed identifiers, compat-container selection, and per-port runtime marker isolation.
State query API parametrization
src/lib/state/gateway.ts, test/gateway-state.test.ts
hasStaleGateway, isGatewayHealthy, getGatewayReuseState, and shouldSelectNamedGatewayForReuse gain a gatewayName parameter (defaulting to legacy constant) and now compare detected identity against the provided name; tests cover per-port classification.
Docker launch container name parametrization
src/lib/onboard/docker-driver-gateway-launch.ts
BuildGatewayLaunchOptions adds optional compatContainerName; buildDockerDriverGatewayLaunch prefers the trimmed override or per-port computed name over the process-wide env value.
Onboarding flow integration
src/lib/onboard.ts, src/lib/onboard/*
Onboarding now resolves GATEWAY_NAME from GATEWAY_PORT, binds classifier helpers to that name, uses per-port state directory under ~/.local/state/nemoclaw/, passes compatContainerName into runtime identity on refresh/start, and persists gatewayName/gatewayPort for new and reused sandboxes.
Sandbox registry schema and persistence
src/lib/state/registry.ts, test/registry.test.ts
SandboxEntry adds optional gatewayName and gatewayPort; registerSandbox persists these fields so sandbox records retain per-port gateway bindings; tests ensure distinct persisted bindings.
Gateway reuse and drift detection adaptation
src/lib/onboard/gateway-reuse.ts, src/lib/adapters/openshell/gateway-drift.ts
Gateway reuse snapshot creation and cluster-active/drift checks now pass the resolved gatewayName to state queries so decisions are gateway-name-aware.
Integration and regression tests
src/lib/onboard/gateway-binding.test.ts, test/gateway-state.test.ts, test/registry.test.ts
New and expanded tests validate port-aware naming, Docker compat container scoping, per-port runtime marker isolation, per-port health/reuse classification, and persisted registry separation.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Onboard as Onboarding Flow
  participant Resolver as Gateway Binding Resolvers
  participant Classifier as Gateway State Classifiers
  participant Docker as Docker Driver
  participant Registry as Sandbox Registry

  User->>Onboard: start onboard (GATEWAY_PORT)
  Onboard->>Resolver: resolveGatewayName/StateDir/CompatContainer(GATEWAY_PORT)
  Resolver-->>Onboard: GATEWAY_NAME, stateDirName, compatContainerName
  Onboard->>Classifier: isGatewayHealthy/getGatewayReuseState(..., GATEWAY_NAME)
  Classifier-->>Onboard: health/reuse/stale decision
  Onboard->>Docker: build/run with compatContainerName, use stateDirName
  Docker-->>Onboard: runtime identity and marker files
  Onboard->>Registry: registerSandbox(..., gatewayName, gatewayPort)
  Registry-->>Onboard: persisted sandbox entry
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

fix, Sandbox, Docker, OpenShell, v0.0.57

Suggested reviewers

  • cv
  • cjagwani

Poem

🐰 I nibbled port-strings in the night,
Suffixes stitched so gateways sit right,
Each sandbox hops its separate fence,
No more shared-gate interference,
Two ports, two homes — a rabbit's delight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: binding gateway name, state directory, and runtime markers per gateway port, directly addressing the core fix in issue #4422.
Linked Issues check ✅ Passed The PR fully addresses all coding objectives from #4422: per-port gateway identity, isolated state directories, distinct compat container names, port-aware health classification, and sandboxes with independent lifecycle markers.
Out of Scope Changes check ✅ Passed All changes are scoped to supporting concurrent gateways on different ports; no unrelated modifications introduced outside the defined objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@yimoj
yimoj force-pushed the fix/4422-support-per-sandbox-gateway-port branch from 59d6191 to 0125eae Compare June 2, 2026 05:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/onboard.ts`:
- Line 1237: Duplicate construction of the Docker-driver runtime identity (the
long call to dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity
with gatewayBin, baseDesiredEnv/gatewayEnv, getDockerDriverGatewayStateDir(),
resolveOpenShellSandboxBinary(), and
resolveGatewayCompatContainerName(GATEWAY_PORT)) should be extracted into a
small helper function (e.g., buildDockerDriverRuntimeIdentity or
getRuntimeIdentity) that returns the same object; replace both call sites where
runtimeIdentity is assigned (the current inline call and the other duplicated
occurrence) with calls to that helper, passing in gatewayBin and any contextual
inputs (or capturing shared values like baseDesiredEnv and GATEWAY_PORT inside
the helper) to reduce duplication and complexity while keeping behavior
identical.
- Around line 3860-3861: Early-return reuse paths update dashboard metadata but
skip persisting gateway fields, leaving legacy registry shapes; ensure those
branches also write gatewayName and gatewayPort. Locate the reuse-return blocks
that call the dashboard update helper (the branches that return early after
updating metadata) and add the gateway fields (gatewayName: GATEWAY_NAME,
gatewayPort: GATEWAY_PORT) into the object passed to the registry
persistence/update function (or call the same save codepath used by fresh
registration) so the registry is backfilled on reuse. Make sure you use the
existing constants GATEWAY_NAME and GATEWAY_PORT and the same persistence helper
(the function that saves registry metadata) to avoid divergence.

In `@src/lib/onboard/docker-driver-gateway-launch.ts`:
- Around line 295-297: The code uses
env.NEMOCLAW_OPENSHELL_GATEWAY_COMPAT_CONTAINER_NAME before
options.compatContainerName when computing containerName, so the global env
always overrides per-sandbox names; change the selection to prefer
options.compatContainerName (trimmed) first, then fall back to the env var, then
DEFAULT_COMPAT_CONTAINER_NAME, and pass that chosen value into safeDockerName
(e.g., compute a chosenName = options.compatContainerName?.trim() ||
env.NEMOCLAW_OPENSHELL_GATEWAY_COMPAT_CONTAINER_NAME ||
DEFAULT_COMPAT_CONTAINER_NAME and use that when calling safeDockerName to set
containerName).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 449a53ca-bb44-4435-b729-5021091d3f24

📥 Commits

Reviewing files that changed from the base of the PR and between cab8f39 and 59d6191.

📒 Files selected for processing (11)
  • src/lib/adapters/openshell/gateway-drift.ts
  • src/lib/core/ports.ts
  • src/lib/onboard.ts
  • src/lib/onboard/docker-driver-gateway-launch.ts
  • src/lib/onboard/gateway-binding.test.ts
  • src/lib/onboard/gateway-binding.ts
  • src/lib/onboard/gateway-reuse.ts
  • src/lib/state/gateway.ts
  • src/lib/state/registry.ts
  • test/gateway-state.test.ts
  • test/registry.test.ts

Comment thread src/lib/onboard.ts Outdated
runCaptureOpenshell(["--version"], { ignoreError: true }),
);
const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, gatewayEnv: baseDesiredEnv, stateDir: getDockerDriverGatewayStateDir(), sandboxBin: resolveOpenShellSandboxBinary() }) : null;
const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, gatewayEnv: baseDesiredEnv, stateDir: getDockerDriverGatewayStateDir(), sandboxBin: resolveOpenShellSandboxBinary(), compatContainerName: resolveGatewayCompatContainerName(GATEWAY_PORT) }) : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract the Docker-driver runtime-identity builder.

This argument bundle is duplicated in both call sites. With the growth guardrail already failing for src/lib/onboard.ts, a small helper here would reduce drift risk and buy back a few lines.

♻️ Proposed refactor
+function buildDockerDriverRuntimeIdentity(
+  gatewayBin: string | null,
+  gatewayEnv: Record<string, string>,
+  stateDir: string = getDockerDriverGatewayStateDir(),
+) {
+  if (!gatewayBin) return null;
+  return dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({
+    gatewayBin,
+    gatewayEnv,
+    stateDir,
+    sandboxBin: resolveOpenShellSandboxBinary(),
+    compatContainerName: resolveGatewayCompatContainerName(GATEWAY_PORT),
+  });
+}
+
 async function refreshDockerDriverGatewayReuseState(
   gatewayReuseState: GatewayReuseState,
 ): Promise<GatewayReuseState> {
@@
-  const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, gatewayEnv: baseDesiredEnv, stateDir: getDockerDriverGatewayStateDir(), sandboxBin: resolveOpenShellSandboxBinary(), compatContainerName: resolveGatewayCompatContainerName(GATEWAY_PORT) }) : null;
+  const runtimeIdentity = buildDockerDriverRuntimeIdentity(
+    gatewayBin,
+    baseDesiredEnv,
+  );
@@
-  const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, gatewayEnv, stateDir, sandboxBin: resolveOpenShellSandboxBinary(), compatContainerName: resolveGatewayCompatContainerName(GATEWAY_PORT) }) : null;
+  const runtimeIdentity = buildDockerDriverRuntimeIdentity(
+    gatewayBin,
+    gatewayEnv,
+    stateDir,
+  );
As per coding guidelines, keep function complexity low in JavaScript and TypeScript code.

Also applies to: 2461-2461

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` at line 1237, Duplicate construction of the Docker-driver
runtime identity (the long call to
dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity with
gatewayBin, baseDesiredEnv/gatewayEnv, getDockerDriverGatewayStateDir(),
resolveOpenShellSandboxBinary(), and
resolveGatewayCompatContainerName(GATEWAY_PORT)) should be extracted into a
small helper function (e.g., buildDockerDriverRuntimeIdentity or
getRuntimeIdentity) that returns the same object; replace both call sites where
runtimeIdentity is assigned (the current inline call and the other duplicated
occurrence) with calls to that helper, passing in gatewayBin and any contextual
inputs (or capturing shared values like baseDesiredEnv and GATEWAY_PORT inside
the helper) to reduce duplication and complexity while keeping behavior
identical.

Comment thread src/lib/onboard.ts
Comment thread src/lib/onboard/docker-driver-gateway-launch.ts Outdated
…rt (NVIDIA#4422)

A second sandbox onboarded with NEMOCLAW_GATEWAY_PORT recreated the
process-global `nemoclaw` gateway and reused the singleton Docker-driver
state directory, so creating it tore down the first sandbox's gateway and
overwrote its runtime marker.

Derive a per-port gateway binding: the default port keeps the bare
`nemoclaw` name, state dir, and compat container for backward
compatibility, while a non-default port yields `nemoclaw-<port>`,
`openshell-docker-gateway-<port>`, and `nemoclaw-openshell-gateway-<port>`.
This isolates each sandbox's gateway registration, pid file, runtime
marker, and compatibility container so a second onboard no longer
recreates, kills, or overwrites the first sandbox's gateway.

- Add gateway-binding resolver (default-port-preserving) and thread it
  through onboard gateway name, Docker-driver state dir, and compat
  container name.
- Make gateway health/reuse classifiers gateway-name aware (defaults
  preserve singleton behavior); bind them to the resolved name in onboard,
  gateway-reuse, and the cluster-active drift probe.
- Persist gatewayName/gatewayPort per sandbox in the registry.
- Add regression coverage: distinct runtime markers for two gateway ports
  (second onboard does not overwrite/invalidate the first), per-port compat
  container naming, port-aware health classification, and registry
  persistence.

Signed-off-by: Yimo Jiang <yimoj@nvidia.com>
@yimoj
yimoj force-pushed the fix/4422-support-per-sandbox-gateway-port branch from 0125eae to 7e47e05 Compare June 2, 2026 05:59
@yimoj

yimoj commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Addressed the findings:

  • compatContainerName precedence (docker-driver-gateway-launch.ts): the per-port compatContainerName now wins over NEMOCLAW_OPENSHELL_GATEWAY_COMPAT_CONTAINER_NAME, so a process-wide env var can no longer collapse two sandboxes onto one compat container. The env override still applies when no per-port name is supplied (test updated accordingly).
  • Backfill gateway metadata on reuse paths (onboard.ts): both ready-sandbox reuse returns now persist gatewayName/gatewayPort via updateSandbox, not just fresh registration.
  • Extract the duplicated runtime-identity builder: declining this one — src/lib/onboard.ts is under the net-neutral growth guardrail, and adding a local helper there grows the file (the two call sites are single-line invocations of an already-extracted library function). Kept the per-port classifier binding in gateway-binding.ts instead to hold onboard.ts net-neutral (+21/-21).

@yimoj yimoj added the v0.0.57 label Jun 2, 2026
@cv
cv merged commit 3f4d8e6 into NVIDIA:main Jun 2, 2026
21 checks passed
cv pushed a commit that referenced this pull request Jun 3, 2026
## Summary
- Add the missing `v0.0.57` release-notes section with links to the
detailed docs pages for command, inference, onboarding, messaging,
status, installer, and policy changes.
- Remove public references to docs-skip terms from source docs and
regenerate the NemoClaw user skills from the current Fern MDX docs.
- Carry forward generated references for the per-agent documentation
split, including Hermes-specific reference files.

## Source summary
- #4615 and #4653 -> `docs/about/release-notes.mdx`,
`docs/reference/commands.mdx`: Release notes now cover host-side
`sessions` and `agents` commands plus `NEMOCLAW_EXTRA_AGENTS_JSON`
secondary-agent baking.
- #4163, #4204, #4611, #4619, and #4676 ->
`docs/about/release-notes.mdx`,
`docs/inference/use-local-inference.mdx`: Release notes now cover
managed vLLM progress/readiness, DGX Spark model default changes, local
Ollama streaming usage, and inference route divergence warnings.
- #4267, #4601, #4609, #4642, #4645, and #4661 ->
`docs/about/release-notes.mdx`, `docs/reference/commands.mdx`: Release
notes now cover UFW auto-remediation, local-inference reachability
gates, gateway reuse/binding, cancel rollback, and policy selection
persistence.
- #4577, #4582, #4607, and #4660 -> `docs/about/release-notes.mdx`,
`docs/manage-sandboxes/messaging-channels.mdx`: Release notes now cover
Slack validation, atomic `channels add`, WhatsApp QR diagnostics, and
Slack placeholder normalization.
- #4388, #4600, #4646, and #4647 -> `docs/about/release-notes.mdx`,
`docs/reference/commands.mdx`: Release notes now cover status failure
layers, paused-container hints, Docker-driver doctor behavior, and
non-destructive stale-registry recovery.
- #4569, #4579, and #4678 -> `docs/about/release-notes.mdx`,
`docs/manage-sandboxes/lifecycle.mdx`,
`docs/network-policy/integration-policy-examples.mdx`: Release notes now
cover installer tag pinning, PyPI `uv` policy access, and observable
Jira validation.
- #4632 -> `.agents/skills/`: Regenerated user skills from the current
per-agent docs source, including newly generated Hermes reference files.

## Verification
- `python3 scripts/docs-to-skills.py docs/ .agents/skills/ --prefix
nemoclaw-user --doc-platform fern-mdx`
- `rg "permissive mode|shields down|shields up|shields status|config
rotate-token|rotate-token" docs --glob "*.mdx"`
- `rg "permissive mode|shields down|shields up|shields status|config
rotate-token|rotate-token" .agents/skills --glob "*.md"`
- `npm run docs`
- `npm run build:cli`
- Commit hooks: markdownlint, docs-to-skills verification, gitleaks,
skills YAML, commitlint

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Restructured documentation to clearly distinguish OpenClaw and Hermes
agent variants throughout user guides.
* Enhanced security, credential storage, and deployment guidance with
clearer setup flows.
  * Added Hermes plugin installation and ecosystem documentation.
* Improved workspace, messaging, and policy management references with
variant-specific command examples.
  * Refined troubleshooting and CLI reference sections for clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
yimoj added a commit to yimoj/NemoClaw that referenced this pull request Jun 10, 2026
…NVIDIA#4497)

PR NVIDIA#4647 stopped `connect` from deleting the registry entry of a sandbox
that is registered locally but absent from a healthy gateway, so the
`rebuild --yes` hint `status` prints would have something to recover. But
`rebuild` still dead-ended: its backup step aborted with "Sandbox '<name>'
is not running. Cannot back up state." whenever the live sandbox was
missing — exactly the stale state — so the recommended recovery path stayed
broken and the issue reopened.

Treat a registered-but-not-live sandbox as a recovery rebuild: there is no
live workspace state to back up, so skip the backup/restore steps and
recreate from the preserved registry + onboard-session metadata. Guard the
no-backup path so it never destroys live state:

- Confirm absence authoritatively against the NAMED nemoclaw gateway
  (getReconciledSandboxGatewayState runs `sandbox get`), and only treat
  `present` as live when nemoclaw is the active healthy gateway — a foreign
  active gateway (multi-gateway, NVIDIA#4645) or a list/get inconsistency must not
  trigger a destructive recreate.
- Refuse the destructive path for a sandbox recorded on a non-default
  per-port gateway; point the operator at `openshell gateway select`.
- On recreate failure, restore the captured registry entry verbatim (under
  the registry lock, target-only) so `rebuild`/`connect` stay retryable
  without clobbering concurrent changes; reclaim the default pointer.
- Reset the gone sandbox's stale shields seal only after a successful
  recreate, and tell the operator to re-apply `shields up` if it was locked.

Add a focused regression suite (rebuild-stale-recovery.test.ts) covering the
recover, control, multi-gateway, per-port, and failed-recreate cases; update
the NVIDIA#2276/NVIDIA#4497 reconcile Scenario 14 and the gateway-drift retry test to
assert recovery instead of the dead-end; and strengthen the double-onboard
E2E so the exact reporter workflow (status -> connect -> rebuild --yes) must
recreate a live sandbox — the old probe only checked for "does not exist"
and would have passed against this bug.

Signed-off-by: Yimo Jiang <yimoj@nvidia.com>
cv pushed a commit that referenced this pull request Jun 10, 2026
…#4497) (#5034)

## Summary

`rebuild --yes` no longer dead-ends on a stale sandbox. When a sandbox
is registered locally but absent from a healthy OpenShell gateway
(stuck/diverged provision, container reaped), rebuild now treats it as a
recovery: it skips the impossible backup and recreates from the
preserved registry + onboard-session metadata, instead of aborting with
"Cannot back up state."

## Related Issue

Fixes #4497

## Changes

- `rebuild`: when the sandbox is missing from the active gateway's
`sandbox list`, confirm absence authoritatively against the **named**
nemoclaw gateway (`getReconciledSandboxGatewayState` runs `sandbox
get`). Only enter the destructive stale-recovery path once it is
genuinely gone on a healthy named gateway.
- Stale recovery skips backup/restore and recreates from
registry/session metadata; built-in policy presets are re-applied from
`sb.policies` (the same source the backup manifest uses).
- **Multi-gateway data-loss guards:** never recreate-from-scratch when
(a) a foreign gateway is active (a `present`/list result there is not
trusted), or (b) the sandbox is recorded on a non-default per-port
gateway (#4645) — the operator is pointed at `openshell gateway select`
instead.
- **Crash-safety:** on recreate failure, restore the captured registry
entry verbatim (target-only, under the registry lock — no clobbering
concurrent changes) and reclaim the default-sandbox pointer, so
`rebuild`/`connect` stay retryable.
- **Shields:** reset the gone sandbox's stale lock seal only *after* a
successful recreate (a failed recreate keeps the lockdown record for
retry), and prompt the operator to re-apply `shields up` if it had been
locked.
- New `registry.restoreSandboxEntry` and `shields.clearShieldsState`
helpers.

## Type of Change

- [x] Code change (feature, bug fix, or refactor)

## Verification

- [x] `npm test` passes (only the pre-existing
`test/ssrf-parity.test.ts` fails, identically on clean `upstream/main` —
unrelated to this change)
- [x] Tests added or updated for new or changed behavior
- [x] No secrets, API keys, or credentials committed
- [x] `npx biome check` clean on changed files; `shfmt`/`shellcheck`
clean on the E2E script; `tsc` type-check passes

### Local end-to-end proof (exact reporter workflow on a real OpenShell
gateway)

Drove `status -> connect -> rebuild --yes` against a real healthy
gateway with a registered-but-not-live sandbox:

```
⚠ Sandbox 'repro4497stale' is registered locally but absent from the live OpenShell gateway.
No live workspace state to back up — recreating from the preserved registry metadata.
Deleting old sandbox...
✓ Old sandbox deleted
Creating new sandbox with current image...
```

No "Cannot back up state", no "Backing up sandbox state" — rebuild
crossed the gate that previously dead-ended and proceeded into the
recreate (`onboard --resume`). The user's other registry entries were
untouched.

### Regression + pipeline coverage

- `test/rebuild-stale-recovery.test.ts` — recover, live-control,
foreign-gateway guard, non-default per-port guard, failed-recreate
registry rollback (incl. `defaultSandbox`).
- `test/gateway-state-reconcile-2276.test.ts` Scenario 14 and
`rebuild-gateway-drift.test.ts` updated to assert recovery rather than
the dead-end.
- `test/e2e/test-double-onboard.sh` Phase 5 strengthened so the exact
reporter workflow must recreate a **live** sandbox (the prior probe only
checked for "does not exist" and would have passed against this bug).

### Known limitation (pre-existing, shared with normal rebuild)

Custom `policy-add --from-file/--from-dir` egress rules
(`customPolicies`) are not re-applied to the recreated sandbox — this is
identical to a normal rebuild's recreate path (the registry entry is
removed before `onboard --resume` runs) and is out of scope for this
dead-end fix.

---
Signed-off-by: Yimo Jiang <yimoj@nvidia.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced sandbox recovery when a sandbox exists locally but is missing
from the live gateway, preventing unnecessary destructive operations and
enabling a “stale-sandbox” recovery path.
* Improved rebuild behavior to preserve and restore local registry
metadata so retrying recovery no longer fails.

* **Improvements**
* Updated post-recreate and error messaging to include clearer context
and preserved backup paths; retry guidance is now configurable.
* Shield handling adjusted to avoid unsafe unlock/relock behavior during
stale recovery.

* **New Features**
* Added APIs to restore a removed registry entry and to clear persisted
shields state.

* **Tests**
* Added and expanded end-to-end and unit tests covering stale-sandbox
recovery scenarios and regressions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: Yimo Jiang <yimoj@nvidia.com>
@wscurran wscurran added the NV QA Bugs found by the NVIDIA QA Team label Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression NV QA Bugs found by the NVIDIA QA Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[WSL2 x86_64][Sandbox] NEMOCLAW_GATEWAY_PORT=N onboard recreates global gateway and destroys previous sandbox — concurrent instances unsupported

3 participants