Skip to content
180 changes: 164 additions & 16 deletions src/nemoclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,13 @@ import {
persistChannelTokens,
} from "./lib/sandbox-channels";
import {
OPENSHELL_DOWNLOAD_TIMEOUT_MS,
OPENSHELL_OPERATION_TIMEOUT_MS,
OPENSHELL_PROBE_TIMEOUT_MS,
} from "./lib/openshell-timeouts";
import { buildChain } from "./lib/dashboard-contract";
import type { DashboardRecoverDeps } from "./lib/dashboard-recover";
import { recoverDashboardChain } from "./lib/dashboard-recover";
const onboardProviders = require("./lib/onboard-providers");

// ── Global commands (derived from command registry) ──────────────
Expand Down Expand Up @@ -290,9 +294,10 @@ function isSandboxGatewayRunning(sandboxName: string): boolean | null {
* Cleans stale lock/temp files, sources proxy config, and launches the gateway
* in the background. Returns true on success.
*/
function recoverSandboxProcesses(sandboxName: string): boolean {
function recoverSandboxProcesses(sandboxName: string, opts: { port?: number } = {}): boolean {
const agent = agentRuntime.getSessionAgent(sandboxName);
const agentScript = agentRuntime.buildRecoveryScript(agent, agent?.forwardPort ?? DASHBOARD_PORT);
const port = opts.port ?? agent?.forwardPort ?? DASHBOARD_PORT;
const agentScript = agentRuntime.buildRecoveryScript(agent, port);
const script =
agentScript ||
[
Expand All @@ -309,7 +314,7 @@ function recoverSandboxProcesses(sandboxName: string): boolean {
"if [ -r /tmp/nemoclaw-proxy-env.sh ]; then . /tmp/nemoclaw-proxy-env.sh; _PE_MISSING=0; else _PE_MISSING=1; fi;",
"[ -f ~/.bashrc ] && . ~/.bashrc;",
'case "${NODE_OPTIONS:-}" in *nemoclaw-sandbox-safety-net*) _GUARDS_MISSING=0 ;; *) _GUARDS_MISSING=1 ;; esac;',
`if curl -sf --max-time 3 http://127.0.0.1:${DASHBOARD_PORT}/ > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`,
`if curl -sf --max-time 3 http://127.0.0.1:${port}/ > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`,
"rm -rf /tmp/openclaw-*/gateway.*.lock 2>/dev/null;",
"rm -f /tmp/gateway.log /tmp/auto-pair.log;",
"touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;",
Expand All @@ -320,7 +325,7 @@ function recoverSandboxProcesses(sandboxName: string): boolean {
'if [ -z "$OPENCLAW" ]; then echo OPENCLAW_MISSING; exit 1; fi;',
// Append rather than truncate so [gateway-recovery] WARNING lines
// written above survive past the launch. (#2478)
`nohup "$OPENCLAW" gateway run --port ${DASHBOARD_PORT} >> /tmp/gateway.log 2>&1 &`,
`nohup "$OPENCLAW" gateway run --port ${port} >> /tmp/gateway.log 2>&1 &`,
"GPID=$!; sleep 2;",
'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',
].join(" ");
Expand All @@ -346,10 +351,68 @@ function ensureSandboxPortForward(sandboxName: string): void {
});
}

/** Build bounded DashboardRecoverDeps wired to real openshell calls. */
function buildDashboardRecoverDeps(): DashboardRecoverDeps {
return {
executeSandboxCommand: (name: string, script: string) => {
const result = executeSandboxCommand(name, script);
if (!result) return null;
return { status: result.status, stdout: result.stdout };
},
captureForwardList: () => {
const result = captureOpenshell(["forward", "list"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
return result.status === 0 ? result.output : null;
},
downloadSandboxConfig: (name: string) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cfg-"));
try {
const destDir = `${tmpDir}${path.sep}`;
const result = runOpenshell(
["sandbox", "download", name, "/sandbox/.openclaw/openclaw.json", destDir],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"], timeout: OPENSHELL_DOWNLOAD_TIMEOUT_MS },
);
if (result.status !== 0) return null;
const files = fs.readdirSync(tmpDir, { recursive: true }) as string[];
const jsonFile = files.find((f) => f.endsWith("openclaw.json"));
if (!jsonFile) return null;
return JSON.parse(fs.readFileSync(path.join(tmpDir, jsonFile), "utf-8"));
} catch {
return null;
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
},
restartGateway: (name: string, port: number, _agent: unknown) => {
const ok = recoverSandboxProcesses(name, { port });
if (ok) sleepSeconds(3); // Wait for HTTP listener to bind before re-verify
return ok;
},
stopForward: (port: number) => {
runOpenshell(["forward", "stop", String(port)], {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
},
startForward: (target: string, name: string) => {
runOpenshell(["forward", "start", "--background", target, name], {
ignoreError: true,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
},
getSessionAgent: (name: string) => agentRuntime.getSessionAgent(name),
};
}

/**
* Detect and recover from a sandbox that survived a gateway restart but
* whose OpenClaw processes are not running. Returns an object describing
* the outcome: { checked, wasRunning, recovered }.
* Detect and recover from a sandbox whose dashboard delivery chain is
* unhealthy. Checks all links (gateway process, port forward, CORS) and
* repairs whatever is broken — even when the in-sandbox gateway is alive
* but the forward or CORS link has drifted (e.g. after a pod restart that
* killed only the SSH tunnel). Returns an object describing the outcome:
* { checked, wasRunning, recovered }.
*/
function checkAndRecoverSandboxProcesses(
sandboxName: string,
Expand All @@ -359,23 +422,108 @@ function checkAndRecoverSandboxProcesses(
if (running === null) {
return { checked: false, wasRunning: null, recovered: false };
}

const agent = agentRuntime.getSessionAgent(sandboxName);

// Dashboard chain recovery only applies to OpenClaw sandboxes.
// Non-OpenClaw agents (Hermes, etc.) use different config paths and don't
// expose the OpenClaw control UI — fall back to gateway-only recovery.
if (agent !== null) {
return checkAndRecoverGatewayOnly(sandboxName, running, agent, { quiet });
}

// OpenClaw sandbox: use the registry's persisted dashboardPort (which
// reflects auto-allocated or user-overridden ports) before falling back
// to the default. This ensures multi-sandbox setups with custom ports
// (e.g. 18790) probe and recover the correct port.
const sb = registry.getSandbox(sandboxName);
const port = sb?.dashboardPort ?? DASHBOARD_PORT;

// Fast path: if the gateway is running AND the forward is active, skip
// the expensive chain recovery (which includes a 30s downloadSandboxConfig
// timeout). Only run full chain verification when something is clearly broken.
if (running) {
const forwardOutput = captureOpenshell(["forward", "list"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
const hasForward = forwardOutput.status === 0 &&
forwardOutput.output.split("\n").some((line: string) => line.trim().split(/\s+/)[2] === String(port));
if (hasForward) {
return { checked: true, wasRunning: true, recovered: false };
}
}

const chain = buildChain({ port, chatUiUrl: process.env.CHAT_UI_URL });
Comment on lines +426 to +457

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Let buildChain() own the fallback port resolution.

Line 423 always supplies a port, so buildChain() never gets to derive one from CHAT_UI_URL. That breaks custom loopback/local-port setups by verifying CORS and forward state against the default port instead of the configured URL.

Suggested fix
   const agent = agentRuntime.getSessionAgent(sandboxName);
-  const port = agent?.forwardPort ?? DASHBOARD_PORT;
-  const chain = buildChain({ port, chatUiUrl: process.env.CHAT_UI_URL });
+  const chain = buildChain({
+    port: agent?.forwardPort,
+    chatUiUrl: process.env.CHAT_UI_URL,
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const agent = agentRuntime.getSessionAgent(sandboxName);
const port = agent?.forwardPort ?? DASHBOARD_PORT;
const chain = buildChain({ port, chatUiUrl: process.env.CHAT_UI_URL });
const agent = agentRuntime.getSessionAgent(sandboxName);
const chain = buildChain({
port: agent?.forwardPort,
chatUiUrl: process.env.CHAT_UI_URL,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 422 - 424, The code pre-resolves a fallback
port (const port = agent?.forwardPort ?? DASHBOARD_PORT) before calling
buildChain, preventing buildChain from deriving a port from CHAT_UI_URL; change
to pass the raw forwardPort (const port = agent?.forwardPort) and call
buildChain({ port, chatUiUrl: process.env.CHAT_UI_URL }) so buildChain can
perform its own fallback/derivation logic (referencing
agentRuntime.getSessionAgent, agent?.forwardPort, DASHBOARD_PORT, and
buildChain).

const deps = buildDashboardRecoverDeps();
const result = recoverDashboardChain(sandboxName, chain, deps);

// Chain was already healthy — nothing to do
if (!result.attempted) {
return { checked: true, wasRunning: running, recovered: false };
}

// Recovery was attempted — report progress
if (!quiet && !running) {
console.log("");
console.log(" OpenClaw gateway is not running inside the sandbox (sandbox likely restarted).");
console.log(" Recovering...");
}

if (result.after?.healthy) {
if (!quiet) {
for (const action of result.actions) {
console.log(` ${G}✓${R} ${action}`);
}
}
return { checked: true, wasRunning: running, recovered: true };
}

// Chain recovery didn't fully succeed — report diagnosis
if (!quiet) {
if (result.actions.length > 0) {
for (const action of result.actions) {
console.log(` • ${action}`);
}
}
if (result.after && !result.after.healthy) {
console.error(` Recovery incomplete: ${result.after.diagnosis || "unknown"}`);
}
console.error(" Could not fully recover OpenClaw dashboard chain.");
console.error(" Connect to the sandbox and run manually:");
console.error(` ${agentRuntime.getGatewayCommand(agent)}`);
}

return { checked: true, wasRunning: running, recovered: false };
}

/**
* Simplified gateway-only recovery for non-OpenClaw agents (Hermes, etc.).
* Only checks/restarts the gateway process and re-establishes the port forward.
* Does NOT attempt CORS verification (agents use different config paths).
*/
function checkAndRecoverGatewayOnly(
sandboxName: string,
running: boolean,
agent: unknown,
{ quiet = false }: { quiet?: boolean } = {},
) {
if (running) {
return { checked: true, wasRunning: true, recovered: false };
}

// Gateway not running — attempt recovery
const _recoveryAgent = agentRuntime.getSessionAgent(sandboxName);
if (!quiet) {
console.log("");
console.log(
` ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway is not running inside the sandbox (sandbox likely restarted).`,
` ${agentRuntime.getAgentDisplayName(agent)} gateway is not running inside the sandbox (sandbox likely restarted).`,
);
console.log(" Recovering...");
}

const recovered = recoverSandboxProcesses(sandboxName);
const sb = registry.getSandbox(sandboxName);
const port = sb?.dashboardPort ?? (agent as { forwardPort?: number })?.forwardPort ?? DASHBOARD_PORT;
const recovered = recoverSandboxProcesses(sandboxName, { port });
if (recovered) {
// Wait for gateway to bind its HTTP port before declaring success
sleepSeconds(3);
if (isSandboxGatewayRunning(sandboxName) !== true) {
if (!quiet) {
Expand All @@ -387,16 +535,16 @@ function checkAndRecoverSandboxProcesses(
ensureSandboxPortForward(sandboxName);
if (!quiet) {
console.log(
` ${G}✓${R} ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway restarted inside sandbox.`,
` ${G}✓${R} ${agentRuntime.getAgentDisplayName(agent)} gateway restarted inside sandbox.`,
);
console.log(` ${G}✓${R} Dashboard port forward re-established.`);
console.log(` ${G}✓${R} Port forward re-established.`);
}
} else if (!quiet) {
console.error(
` Could not restart ${agentRuntime.getAgentDisplayName(_recoveryAgent)} gateway automatically.`,
` Could not restart ${agentRuntime.getAgentDisplayName(agent)} gateway automatically.`,
);
console.error(" Connect to the sandbox and run manually:");
console.error(` ${agentRuntime.getGatewayCommand(_recoveryAgent)}`);
console.error(` ${agentRuntime.getGatewayCommand(agent)}`);
Comment on lines +482 to +547

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tailor the fallback remediation to the failed link.

After chain recovery fails, this always tells the user to run the gateway manually. That is misleading when the only broken link is CORS, and the printed command also omits the resolved chain.port, so non-default-port setups get the wrong manual instruction too.

Suggested fix
   if (!quiet) {
     if (result.actions.length > 0) {
       for (const action of result.actions) {
         console.log(`  • ${action}`);
       }
     }
     if (result.after && !result.after.healthy) {
       console.error(`  Recovery incomplete: ${result.after.diagnosis || "unknown"}`);
     }
+    const failedLinks = result.after?.links ?? result.before.links;
     console.error(
       `  Could not fully recover ${agentRuntime.getAgentDisplayName(agent)} dashboard chain.`,
     );
-    console.error("  Connect to the sandbox and run manually:");
-    console.error(`    ${agentRuntime.getGatewayCommand(agent)}`);
+    if (!failedLinks.gateway.ok) {
+      console.error("  Connect to the sandbox and run manually:");
+      console.error(`    ${agentRuntime.getGatewayCommand(agent)} --port ${chain.port}`);
+    } else if (!failedLinks.cors.ok) {
+      console.error("  Rebuild the sandbox to refresh the dashboard allowedOrigins.");
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 455 - 469, The fallback message always points
users to run the gateway manually and omits the resolved chain port; update the
post-recovery messaging in the block that checks result.after and prints the
manual command (around result.actions/result.after handling) to: inspect
result.after.diagnosis (or any failed-link indicator on result.after) and choose
a tailored remediation (e.g., if diagnosis indicates only CORS, print a
CORS-specific hint instead of the gateway command), and when you do print the
gateway command via agentRuntime.getGatewayCommand(agent) include the actual
resolved port from the agent/chain object (e.g., agent.chain.port or
agent.getResolvedPort()) so non-default ports are shown; modify the conditional
that logs the manual instruction to branch on the failure type and to include
the resolved chain.port in the constructed command.

}

return { checked: true, wasRunning: false, recovered };
Expand Down
Loading