Skip to content

Commit cfa7682

Browse files
ericksoagemini2026
authored andcommitted
feat: sandbox survival across gateway restarts (NVIDIA#1466)
## Summary - **Bump minimum OpenShell to v0.0.22** — enables sandbox persistence across gateway restarts (deterministic k3s node name + workspace PVC + gateway resume from volume) - **Auto-recover OpenClaw processes** — when the sandbox pod survives a restart but the OpenClaw gateway didn't re-run, `nemoclaw connect` and `nemoclaw status` detect it and transparently restart via SSH - **E2E test** — proves the full survival scenario with real NVIDIA inference: onboard → baseline inference → plant marker file → stop gateway → restart gateway → verify sandbox survived → verify marker persisted → verify inference works post-restart (24/24 tests passed) ### How it works (joint OpenShell + NemoClaw solution) OpenShell v0.0.22 persists the infrastructure layer: - Gateway resumes from Docker volume state (PR NVIDIA/OpenShell#488) - SSH handshake secrets survive as K8s Secrets (PR NVIDIA/OpenShell#488) - Deterministic k3s node name prevents PVC orphaning (PR NVIDIA/OpenShell#739) - Default 1Gi workspace PVC at `/sandbox` (PR NVIDIA/OpenShell#739) NemoClaw restores the application layer: - Detects "sandbox alive, OpenClaw dead" via HTTP probe (curl localhost:18789) - Cleans stale lock/temp files, restarts gateway via SSH - Re-establishes dashboard port forward (18789) - `nemoclaw status` shows `OpenClaw: running | recovered | not running` with guidance ### User experience after this PR ``` laptop closes → Docker stops → laptop opens → Docker auto-restarts container → OpenShell gateway resumes, sandbox pod reschedules with workspace intact → user runs: nemoclaw my-assistant connect → NemoClaw detects OpenClaw not running, auto-restarts, reconnects port forward → user is back where they left off ``` ### Context Reported by @SenthilKumar-Ravichandran after testing OpenShell v0.0.22 on Brev VMs — PVC persistence works, but the user-defined ENTRYPOINT (`nemoclaw-start`) does not re-run after pod restart on some platforms. ## Test plan - [x] Unit tests pass (826/826 in main working directory) - [x] E2E sandbox survival test passes with real NVIDIA inference (24/24) - [x] `nemoclaw status` shows `OpenClaw: running` when gateway is alive - [x] `nemoclaw status` shows `OpenClaw: recovered` after auto-restart - [x] ShellCheck passes on new E2E test - [ ] Validate on Brev VM (where ENTRYPOINT doesn't re-run) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Adds automatic gateway health monitoring with in-sandbox recovery attempts, re-establishes dashboard port-forwarding, and provides clearer status output with actionable recovery instructions. * **Tests** * Adds an end-to-end test validating sandbox persistence and continuity across gateway stop/start cycles, including live inference and marker-file persistence checks. * **Chores** * Bumped minimum OpenShell version requirement to 0.0.22. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 2fd4cf7 commit cfa7682

3 files changed

Lines changed: 669 additions & 2 deletions

File tree

bin/nemoclaw.js

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,167 @@ function stripAnsi(value = "") {
175175
return String(value).replace(/\x1b\[[0-9;]*m/g, "");
176176
}
177177

178+
// ── Sandbox process health (OpenClaw gateway inside the sandbox) ─────────
179+
180+
/**
181+
* Run a command inside the sandbox via SSH and return { status, stdout, stderr }.
182+
* Returns null if SSH config cannot be obtained.
183+
*/
184+
function executeSandboxCommand(sandboxName, command) {
185+
const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], {
186+
ignoreError: true,
187+
});
188+
if (sshConfigResult.status !== 0) return null;
189+
190+
const tmpFile = path.join(os.tmpdir(), `nemoclaw-ssh-${process.pid}-${Date.now()}.conf`);
191+
fs.writeFileSync(tmpFile, sshConfigResult.output, { mode: 0o600 });
192+
try {
193+
const result = spawnSync(
194+
"ssh",
195+
[
196+
"-F",
197+
tmpFile,
198+
"-o",
199+
"StrictHostKeyChecking=no",
200+
"-o",
201+
"UserKnownHostsFile=/dev/null",
202+
"-o",
203+
"ConnectTimeout=5",
204+
"-o",
205+
"LogLevel=ERROR",
206+
`openshell-${sandboxName}`,
207+
command,
208+
],
209+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 },
210+
);
211+
return {
212+
status: result.status ?? 1,
213+
stdout: (result.stdout || "").trim(),
214+
stderr: (result.stderr || "").trim(),
215+
};
216+
} catch {
217+
return null;
218+
} finally {
219+
try {
220+
fs.unlinkSync(tmpFile);
221+
} catch {
222+
/* ignore */
223+
}
224+
}
225+
}
226+
227+
/**
228+
* Check whether the OpenClaw gateway process is running inside the sandbox.
229+
* Uses the gateway's HTTP endpoint (port 18789) as the source of truth,
230+
* since the gateway runs as a separate user and pgrep may not see it.
231+
* Returns true (running), false (stopped), or null (cannot determine).
232+
*/
233+
function isSandboxGatewayRunning(sandboxName) {
234+
const result = executeSandboxCommand(
235+
sandboxName,
236+
"curl -sf --max-time 3 http://127.0.0.1:18789/ > /dev/null 2>&1 && echo RUNNING || echo STOPPED",
237+
);
238+
if (!result) return null;
239+
if (result.stdout === "RUNNING") return true;
240+
if (result.stdout === "STOPPED") return false;
241+
return null;
242+
}
243+
244+
/**
245+
* Restart the OpenClaw gateway process inside the sandbox after a pod restart.
246+
* Cleans stale lock/temp files, sources proxy config, and launches the gateway
247+
* in the background. Returns true on success.
248+
*/
249+
function recoverSandboxProcesses(sandboxName) {
250+
// The recovery script runs as the sandbox user (non-root). This matches
251+
// the non-root fallback path in nemoclaw-start.sh — no privilege
252+
// separation, but the gateway runs and inference works.
253+
const script = [
254+
// Source proxy config (written to .bashrc by nemoclaw-start on first boot)
255+
"[ -f ~/.bashrc ] && . ~/.bashrc 2>/dev/null;",
256+
// Re-check liveness before touching anything — another caller may have
257+
// already recovered the gateway between our initial check and now (TOCTOU).
258+
"if curl -sf --max-time 3 http://127.0.0.1:18789/ > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;",
259+
// Clean stale lock files from the previous run (gateway checks these)
260+
"rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;",
261+
// Clean stale temp files from the previous run
262+
"rm -f /tmp/gateway.log /tmp/auto-pair.log;",
263+
"touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;",
264+
"touch /tmp/auto-pair.log; chmod 600 /tmp/auto-pair.log;",
265+
// Resolve and start gateway
266+
'OPENCLAW="$(command -v openclaw)";',
267+
'if [ -z "$OPENCLAW" ]; then echo OPENCLAW_MISSING; exit 1; fi;',
268+
'nohup "$OPENCLAW" gateway run > /tmp/gateway.log 2>&1 &',
269+
"GPID=$!; sleep 2;",
270+
// Verify the gateway actually started (didn't crash immediately)
271+
'if kill -0 "$GPID" 2>/dev/null; then echo "GATEWAY_PID=$GPID"; else echo GATEWAY_FAILED; cat /tmp/gateway.log 2>/dev/null | tail -5; fi',
272+
].join(" ");
273+
274+
const result = executeSandboxCommand(sandboxName, script);
275+
if (!result) return false;
276+
return (
277+
result.status === 0 &&
278+
(result.stdout.includes("GATEWAY_PID=") || result.stdout.includes("ALREADY_RUNNING"))
279+
);
280+
}
281+
282+
/**
283+
* Re-establish the dashboard port forward (18789) to the sandbox.
284+
*/
285+
function ensureSandboxPortForward(sandboxName) {
286+
runOpenshell(["forward", "stop", DASHBOARD_FORWARD_PORT], { ignoreError: true });
287+
runOpenshell(["forward", "start", "--background", DASHBOARD_FORWARD_PORT, sandboxName], {
288+
ignoreError: true,
289+
});
290+
}
291+
292+
/**
293+
* Detect and recover from a sandbox that survived a gateway restart but
294+
* whose OpenClaw processes are not running. Returns an object describing
295+
* the outcome: { checked, wasRunning, recovered }.
296+
*/
297+
function checkAndRecoverSandboxProcesses(sandboxName, { quiet = false } = {}) {
298+
const running = isSandboxGatewayRunning(sandboxName);
299+
if (running === null) {
300+
return { checked: false, wasRunning: null, recovered: false };
301+
}
302+
if (running) {
303+
return { checked: true, wasRunning: true, recovered: false };
304+
}
305+
306+
// Gateway not running — attempt recovery
307+
if (!quiet) {
308+
console.log("");
309+
console.log(" OpenClaw gateway is not running inside the sandbox (sandbox likely restarted).");
310+
console.log(" Recovering...");
311+
}
312+
313+
const recovered = recoverSandboxProcesses(sandboxName);
314+
if (recovered) {
315+
// Wait for gateway to bind its HTTP port before declaring success
316+
spawnSync("sleep", ["3"]);
317+
if (isSandboxGatewayRunning(sandboxName) !== true) {
318+
// Gateway process started but HTTP endpoint never came up
319+
if (!quiet) {
320+
console.error(" Gateway process started but is not responding.");
321+
console.error(" Check /tmp/gateway.log inside the sandbox for details.");
322+
}
323+
return { checked: true, wasRunning: false, recovered: false };
324+
}
325+
ensureSandboxPortForward(sandboxName);
326+
if (!quiet) {
327+
console.log(` ${G}${R} OpenClaw gateway restarted inside sandbox.`);
328+
console.log(` ${G}${R} Dashboard port forward re-established.`);
329+
}
330+
} else if (!quiet) {
331+
console.error(" Could not restart OpenClaw gateway automatically.");
332+
console.error(" Connect to the sandbox and run manually:");
333+
console.error(" nohup openclaw gateway run > /tmp/gateway.log 2>&1 &");
334+
}
335+
336+
return { checked: true, wasRunning: false, recovered };
337+
}
338+
178339
function buildRecoveredSandboxEntry(name, metadata = {}) {
179340
return {
180341
name,
@@ -961,6 +1122,7 @@ async function listSandboxes() {
9611122

9621123
async function sandboxConnect(sandboxName) {
9631124
await ensureLiveSandboxOrExit(sandboxName);
1125+
checkAndRecoverSandboxProcesses(sandboxName);
9641126
const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], {
9651127
stdio: "inherit",
9661128
cwd: ROOT,
@@ -1050,6 +1212,28 @@ async function sandboxStatus(sandboxName) {
10501212
printGatewayLifecycleHint(lookup.output, sandboxName, console.log);
10511213
}
10521214

1215+
// OpenClaw process health inside the sandbox
1216+
if (lookup.state === "present") {
1217+
const processCheck = checkAndRecoverSandboxProcesses(sandboxName, { quiet: true });
1218+
if (processCheck.checked) {
1219+
if (processCheck.wasRunning) {
1220+
console.log(` OpenClaw: ${G}running${R}`);
1221+
} else if (processCheck.recovered) {
1222+
console.log(` OpenClaw: ${G}recovered${R} (gateway restarted after sandbox restart)`);
1223+
} else {
1224+
console.log(` OpenClaw: ${_RD}not running${R}`);
1225+
console.log("");
1226+
console.log(" The sandbox is alive but the OpenClaw gateway process is not running.");
1227+
console.log(" This typically happens after a gateway restart (e.g., laptop close/open).");
1228+
console.log("");
1229+
console.log(" To recover, run:");
1230+
console.log(` ${D}nemoclaw ${sandboxName} connect${R} (auto-recovers on connect)`);
1231+
console.log(" Or manually inside the sandbox:");
1232+
console.log(` ${D}nohup openclaw gateway run > /tmp/gateway.log 2>&1 &${R}`);
1233+
}
1234+
}
1235+
}
1236+
10531237
// NIM health
10541238
const nimStat =
10551239
sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName);

scripts/install-openshell.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ esac
3333

3434
info "Detected $OS_LABEL ($ARCH_LABEL)"
3535

36-
# Minimum version required for cgroup v2 fix (NVIDIA/OpenShell#329)
37-
MIN_VERSION="0.0.7"
36+
# Minimum version required for sandbox persistence across gateway restarts
37+
# (deterministic k3s node name + workspace PVC: NVIDIA/OpenShell#739, #488)
38+
MIN_VERSION="0.0.22"
3839

3940
version_gte() {
4041
# Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat)

0 commit comments

Comments
 (0)