Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 0 additions & 5 deletions src/lib/nemoclaw-runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@

/* v8 ignore start -- transitional bridge until command actions are extracted from src/nemoclaw.ts. */

export interface SandboxConnectOptions {
probeOnly?: boolean;
}

export interface NemoClawRuntimeBridge {
sandboxConnect: (sandboxName: string, options?: SandboxConnectOptions) => Promise<void>;
sandboxDestroy: (sandboxName: string, args?: string[]) => Promise<void>;
sandboxRebuild: (sandboxName: string, args?: string[]) => Promise<void>;
sandboxStatus: (sandboxName: string) => Promise<void>;
Expand Down
327 changes: 327 additions & 0 deletions src/lib/sandbox-connect-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- exercised through CLI subprocess connect tests. */

import { spawnSync } from "node:child_process";
import os from "node:os";

import { CLI_NAME } from "./branding";
import { parseGatewayInference } from "./inference-config";
import { ensureOllamaAuthProxy } from "./onboard-ollama-proxy";
import {
captureOpenshell,
getOpenshellBinary,
runOpenshell,
} from "./openshell-runtime";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts";
import * as registry from "./registry";
import { ROOT } from "./runner";
import { ensureLiveSandboxOrExit } from "./sandbox-gateway-state-action";
import {
createSystemDeps as createSessionDeps,
getActiveSandboxSessions,
} from "./sandbox-session-state";
import { checkAndRecoverSandboxProcesses } from "./sandbox-process-recovery-action";
import * as sandboxVersion from "./sandbox-version";
import { D, G, R, YW } from "./terminal-style";
import { resolveOpenshell } from "./resolve-openshell";

const agentRuntime = require("../../bin/lib/agent-runtime");

export type SandboxConnectOptions = {
probeOnly?: boolean;
};

type SpawnLikeResult = {
status: number | null;
signal?: NodeJS.Signals | null;
};

const SANDBOX_CONNECT_FLAGS = new Set([
"--dangerously-skip-permissions",
"--probe-only",
"--help",
"-h",
]);

export function isSandboxConnectFlag(arg: string | undefined): boolean {
return typeof arg === "string" && SANDBOX_CONNECT_FLAGS.has(arg);
}

export function printSandboxConnectHelp(sandboxName = "<name>"): void {
console.log("");
console.log(` Usage: ${CLI_NAME} ${sandboxName} connect [--probe-only]`);
console.log("");
console.log(" Options:");
console.log(
" --probe-only Run recovery checks and exit without opening SSH",
);
console.log(" -h, --help Show this help");
console.log("");
}

export function parseSandboxConnectArgs(
sandboxName: string,
actionArgs: string[],
): SandboxConnectOptions {
const options: SandboxConnectOptions = {};
for (const arg of actionArgs) {
if (!isSandboxConnectFlag(arg)) {
console.error(` Unknown flag for connect: ${arg}`);
printSandboxConnectHelp(sandboxName);
process.exit(1);
}
switch (arg) {
case "--dangerously-skip-permissions":
console.error(" --dangerously-skip-permissions was removed; use shields commands instead.");
printSandboxConnectHelp(sandboxName);
process.exit(1);
break;
case "--probe-only":
options.probeOnly = true;
break;
case "--help":
case "-h":
printSandboxConnectHelp(sandboxName);
process.exit(0);
break;
}
}
return options;
}

function runSandboxConnectProbe(sandboxName: string): void {
const processCheck = checkAndRecoverSandboxProcesses(sandboxName, { quiet: true });
const agent = agentRuntime.getSessionAgent(sandboxName);
const agentName = agentRuntime.getAgentDisplayName(agent);
if (!processCheck.checked) {
console.error(
` Probe failed: could not inspect the ${agentName} gateway inside sandbox '${sandboxName}'.`,
);
process.exit(1);
}
if (processCheck.wasRunning) {
if (processCheck.forwardRecovered) {
console.log(
` Probe complete: ${agentName} gateway is running in '${sandboxName}'; restored dashboard port forward.`,
);
} else {
console.log(` Probe complete: ${agentName} gateway is running in '${sandboxName}'.`);
}
return;
}
if (processCheck.recovered) {
console.log(` Probe complete: recovered ${agentName} gateway in '${sandboxName}'.`);
return;
}
console.error(
` Probe failed: ${agentName} gateway is not running in '${sandboxName}' and automatic recovery failed.`,
);
console.error(" Check /tmp/gateway.log inside the sandbox for details.");
process.exit(1);
}

function exitWithSpawnResult(result: SpawnLikeResult): void {
if (result.status !== null) {
process.exit(result.status);
}

if (result.signal) {
const signalNumber = os.constants.signals[result.signal];
process.exit(signalNumber ? 128 + signalNumber : 1);
}

process.exit(1);
}

export async function connectSandbox(
sandboxName: string,
{ probeOnly = false }: SandboxConnectOptions = {},
): Promise<void> {
const { isSandboxReady, parseSandboxStatus } = require("./onboard");
await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true });

if (probeOnly) {
return runSandboxConnectProbe(sandboxName);
}

// Version staleness check — warn but don't block
try {
const versionCheck = sandboxVersion.checkAgentVersion(sandboxName);
if (versionCheck.isStale) {
for (const line of sandboxVersion.formatStalenessWarning(sandboxName, versionCheck)) {
console.error(line);
}
}
} catch {
/* non-fatal — don't block connect on version check failure */
}

// Active session hint — inform if already connected in another terminal
try {
const opsBinConnect = resolveOpenshell();
if (opsBinConnect) {
const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBinConnect));
if (sessionResult.detected && sessionResult.sessions.length > 0) {
const count = sessionResult.sessions.length;
console.log(
` ${D}Note: ${count} existing SSH session${count > 1 ? "s" : ""} to '${sandboxName}' detected (another terminal).${R}`,
);
}
}
} catch {
/* non-fatal — don't block connect on session detection failure */
}

checkAndRecoverSandboxProcesses(sandboxName);
// Ensure Ollama auth proxy is running (recovers from host reboots)
ensureOllamaAuthProxy();

// ── Inference route swap (#1248) ──────────────────────────────────
// When the user has multiple sandboxes with different providers, the
// cluster-wide inference.local route may still point at the *other*
// provider. Re-set it to match this sandbox's persisted config.
let sb;
try {
sb = registry.getSandbox(sandboxName);
if (sb && sb.provider && sb.model) {
const live = parseGatewayInference(
captureOpenshell(["inference", "get"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
}).output,
);
if (!live || live.provider !== sb.provider || live.model !== sb.model) {
console.log(
` Switching inference route to ${sb.provider}/${sb.model} for sandbox '${sandboxName}'`,
);
const swapResult = runOpenshell(
["inference", "set", "--provider", sb.provider, "--model", sb.model, "--no-verify"],
{ ignoreError: true },
);
if (swapResult.status !== 0) {
console.error(
` ${YW}Warning: failed to switch inference route — connect will proceed anyway.${R}`,
);
}
}
}
} catch {
/* non-fatal — don't block connect on inference route swap failure */
}

const rawTimeout = process.env.NEMOCLAW_CONNECT_TIMEOUT;
let timeout = 120;
if (rawTimeout !== undefined) {
const parsed = parseInt(rawTimeout, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
console.warn(
` Warning: invalid NEMOCLAW_CONNECT_TIMEOUT="${rawTimeout}", using default 120s`,
);
} else {
timeout = parsed;
}
}
const interval = 3;
const startedAt = Date.now();
const deadline = startedAt + timeout * 1000;
const elapsedSec = () => Math.floor((Date.now() - startedAt) / 1000);
const remainingMs = () => Math.max(1, deadline - Date.now());
const runSandboxList = () =>
captureOpenshell(["sandbox", "list"], {
ignoreError: true,
timeout: remainingMs(),
}).output;

const list = runSandboxList();
if (!isSandboxReady(list, sandboxName)) {
const status = parseSandboxStatus(list, sandboxName);
const TERMINAL = new Set([
"Failed",
"Error",
"CrashLoopBackOff",
"ImagePullBackOff",
"Unknown",
"Evicted",
]);
if (status && TERMINAL.has(status)) {
console.error("");
console.error(` Sandbox '${sandboxName}' is in '${status}' state.`);
console.error(` Run: ${CLI_NAME} ${sandboxName} logs --follow`);
console.error(` Run: ${CLI_NAME} ${sandboxName} status`);
process.exit(1);
}

console.log(` Waiting for sandbox '${sandboxName}' to be ready...`);
let ready = false;
let everSeen = status !== null;
while (Date.now() < deadline) {
const sleepFor = Math.min(interval, remainingMs() / 1000);
if (sleepFor <= 0) break;
spawnSync("sleep", [String(sleepFor)]);
const poll = runSandboxList();
const elapsed = elapsedSec();
if (isSandboxReady(poll, sandboxName)) {
ready = true;
break;
}
const cur = parseSandboxStatus(poll, sandboxName) || "unknown";
if (cur !== "unknown") everSeen = true;
if (TERMINAL.has(cur)) {
console.error("");
console.error(` Sandbox '${sandboxName}' entered '${cur}' state.`);
console.error(` Run: ${CLI_NAME} ${sandboxName} logs --follow`);
console.error(` Run: ${CLI_NAME} ${sandboxName} status`);
process.exit(1);
}
if (!everSeen && elapsed >= 30) {
console.error("");
console.error(` Sandbox '${sandboxName}' not found after ${elapsed}s.`);
console.error(" Check: openshell sandbox list");
process.exit(1);
}
process.stdout.write(`\r Status: ${cur.padEnd(20)} (${elapsed}s elapsed)`);
}

if (!ready) {
console.error("");
console.error(` Timed out after ${timeout}s waiting for sandbox '${sandboxName}'.`);
console.error(" Check: openshell sandbox list");
console.error(
` Override timeout: NEMOCLAW_CONNECT_TIMEOUT=300 ${CLI_NAME} ${sandboxName} connect`,
);
process.exit(1);
}
console.log(`\r Status: ${"Ready".padEnd(20)} (${elapsedSec()}s elapsed)`);
console.log(" Sandbox is ready. Connecting...");
}

// Print a one-shot hint before dropping the user into the sandbox
// shell so a fresh user knows the first thing to type. Without this,
// `nemoclaw <name> connect` lands on a bare bash prompt and users
// ask "now what?" — see #465. Suppress the hint when stdout isn't a
// TTY so scripted callers don't get noise in their pipelines.
if (
process.stdout.isTTY &&
!["1", "true"].includes(String(process.env.NEMOCLAW_NO_CONNECT_HINT || ""))
) {
console.log("");
const agentName = sb?.agent || "openclaw";
const agentCmd = agentName === "openclaw" ? "openclaw tui" : agentName;
console.log(` ${G}✓${R} Connecting to sandbox '${sandboxName}'`);
console.log(
` ${D}Inside the sandbox, run \`${agentCmd}\` to start chatting with the agent.${R}`,
);
console.log(
` ${D}Type \`/exit\` to leave the chat, then \`exit\` to return to the host shell.${R}`,
);
console.log("");
}
const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], {
stdio: "inherit",
cwd: ROOT,
env: process.env,
});
exitWithSpawnResult(result);
}
Loading
Loading