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
432 changes: 432 additions & 0 deletions bin/lib/onboard-session.js

Large diffs are not rendered by default.

914 changes: 808 additions & 106 deletions bin/lib/onboard.js

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions bin/lib/runtime-recovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const onboardSession = require("./onboard-session");

function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return String(text || "").replace(/\x1b\[[0-9;]*m/g, "");
}

function parseLiveSandboxNames(listOutput = "") {
const clean = stripAnsi(listOutput);
const names = new Set();
for (const rawLine of clean.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
if (/^(NAME|No sandboxes found\.?$)/i.test(line)) continue;
if (/^Error:/i.test(line)) continue;
const cols = line.split(/\s+/);
if (cols[0]) {
names.add(cols[0]);
}
}
return names;
}

function classifySandboxLookup(output = "") {
const clean = stripAnsi(output).trim();
if (!clean) {
return { state: "missing", reason: "empty" };
}
if (/sandbox not found|status:\s*NotFound/i.test(clean)) {
return { state: "missing", reason: "not_found" };
}
if (
/transport error|client error|Connection reset by peer|Connection refused|No active gateway|Gateway: .*Error/i.test(
clean
)
) {
return { state: "unavailable", reason: "gateway_unavailable" };
}
return { state: "present", reason: "ok" };
}

function classifyGatewayStatus(output = "") {
const clean = stripAnsi(output).trim();
if (!clean) {
return { state: "inactive", reason: "empty" };
}
if (/Connected/i.test(clean)) {
return { state: "connected", reason: "ok" };
}
if (
/No active gateway|transport error|client error|Connection reset by peer|Connection refused|Gateway: .*Error/i.test(
clean
)
) {
return { state: "unavailable", reason: "gateway_unavailable" };
}
return { state: "inactive", reason: "not_connected" };
}

function shouldAttemptGatewayRecovery({ sandboxState = "missing", gatewayState = "inactive" } = {}) {
return sandboxState === "unavailable" && gatewayState !== "connected";
}

function getRecoveryCommand() {
const session = onboardSession.loadSession();
if (session && session.resumable !== false) {
return "nemoclaw onboard --resume";
}
return "nemoclaw onboard";
}

module.exports = {
classifyGatewayStatus,
classifySandboxLookup,
getRecoveryCommand,
parseLiveSandboxNames,
shouldAttemptGatewayRecovery,
};
9 changes: 5 additions & 4 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ async function recoverNamedGatewayRuntime() {
}

const shouldStartGateway = [before.state, after.state].some((state) =>
["named_unhealthy", "named_unreachable", "connected_other"].includes(state)
["missing_named", "named_unhealthy", "named_unreachable", "connected_other"].includes(state)
);

if (shouldStartGateway) {
Expand Down Expand Up @@ -334,15 +334,16 @@ function exitWithSpawnResult(result) {

async function onboard(args) {
const { onboard: runOnboard } = require("./lib/onboard");
const allowedArgs = new Set(["--non-interactive"]);
const allowedArgs = new Set(["--non-interactive", "--resume"]);
const unknownArgs = args.filter((arg) => !allowedArgs.has(arg));
if (unknownArgs.length > 0) {
console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`);
console.error(" Usage: nemoclaw onboard [--non-interactive]");
console.error(" Usage: nemoclaw onboard [--non-interactive] [--resume]");
process.exit(1);
}
const nonInteractive = args.includes("--non-interactive");
await runOnboard({ nonInteractive });
const resume = args.includes("--resume");
await runOnboard({ nonInteractive, resume });
}

async function setup() {
Expand Down
140 changes: 105 additions & 35 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,38 @@ print_banner() {

print_done() {
local elapsed=$((SECONDS - _INSTALL_START))
local sandbox_name
sandbox_name="$(resolve_default_sandbox_name)"
info "=== Installation complete ==="
printf "\n"
printf " ${C_GREEN}${C_BOLD}NemoClaw${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$elapsed"
printf "\n"
printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n"
printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n"
printf "\n"
printf " ${C_GREEN}Next:${C_RESET}\n"
printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name"
printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET"
if [[ "$ONBOARD_RAN" == true ]]; then
local sandbox_name
sandbox_name="$(resolve_default_sandbox_name)"
printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n"
printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n"
printf "\n"
printf " ${C_GREEN}Next:${C_RESET}\n"
printf " %s$%s nemoclaw %s connect\n" "$C_GREEN" "$C_RESET" "$sandbox_name"
printf " %ssandbox@%s$%s openclaw tui\n" "$C_GREEN" "$sandbox_name" "$C_RESET"
elif [[ "$NEMOCLAW_READY_NOW" == true ]]; then
printf " ${C_GREEN}NemoClaw CLI is ready in this shell.${C_RESET}\n"
printf " ${C_DIM}Onboarding has not run yet.${C_RESET}\n"
printf "\n"
printf " ${C_GREEN}Next:${C_RESET}\n"
printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET"
else
printf " ${C_GREEN}NemoClaw CLI is installed.${C_RESET}\n"
printf " ${C_DIM}Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.${C_RESET}\n"
printf "\n"
printf " ${C_GREEN}Next:${C_RESET}\n"
if [[ -n "$NEMOCLAW_RECOVERY_EXPORT_DIR" ]]; then
printf " %s$%s export PATH=\"%s:\$PATH\"\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_EXPORT_DIR"
fi
if [[ -n "$NEMOCLAW_RECOVERY_PROFILE" ]]; then
printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$NEMOCLAW_RECOVERY_PROFILE"
fi
printf " %s$%s nemoclaw onboard\n" "$C_GREEN" "$C_RESET"
fi
printf "\n"
printf " ${C_BOLD}GitHub${C_RESET} ${C_DIM}https://github.com/nvidia/nemoclaw${C_RESET}\n"
printf " ${C_BOLD}Docs${C_RESET} ${C_DIM}https://docs.nvidia.com/nemoclaw/latest/${C_RESET}\n"
Expand Down Expand Up @@ -218,6 +238,10 @@ MIN_NPM_MAJOR=10
RUNTIME_REQUIREMENT_MSG="NemoClaw requires Node.js >=${MIN_NODE_VERSION} and npm >=${MIN_NPM_MAJOR}."
NEMOCLAW_SHIM_DIR="${HOME}/.local/bin"
ORIGINAL_PATH="${PATH:-}"
NEMOCLAW_READY_NOW=false
NEMOCLAW_RECOVERY_PROFILE=""
NEMOCLAW_RECOVERY_EXPORT_DIR=""
ONBOARD_RAN=false

# Compare two semver strings (major.minor.patch). Returns 0 if $1 >= $2.
# Rejects prerelease suffixes (e.g. "22.16.0-rc.1") to avoid arithmetic errors.
Expand Down Expand Up @@ -248,6 +272,30 @@ ensure_nvm_loaded() {
fi
}

detect_shell_profile() {
local profile="$HOME/.bashrc"
case "$(basename "${SHELL:-}")" in
zsh)
profile="$HOME/.zshrc"
;;
fish)
profile="$HOME/.config/fish/config.fish"
;;
tcsh)
profile="$HOME/.tcshrc"
;;
csh)
profile="$HOME/.cshrc"
;;
*)
if [[ ! -f "$HOME/.bashrc" && -f "$HOME/.profile" ]]; then
profile="$HOME/.profile"
fi
;;
esac
printf "%s" "$profile"
}

# Refresh PATH so that npm global bin is discoverable.
# After nvm installs Node.js the global bin lives under the nvm prefix,
# which may not yet be on PATH in the current session.
Expand Down Expand Up @@ -509,30 +557,30 @@ install_nemoclaw() {
# ---------------------------------------------------------------------------
verify_nemoclaw() {
if command_exists nemoclaw; then
NEMOCLAW_READY_NOW=true
info "Verified: nemoclaw is available at $(command -v nemoclaw)"
return 0
fi

# nemoclaw not on PATH — try to diagnose and suggest a fix
warn "nemoclaw is not on PATH after installation."

local npm_bin
npm_bin="$(npm config get prefix 2>/dev/null)/bin" || true

if [[ -n "$npm_bin" && -x "$npm_bin/nemoclaw" ]]; then
ensure_nemoclaw_shim || true
if command_exists nemoclaw; then
NEMOCLAW_READY_NOW=true
info "Verified: nemoclaw is available at $(command -v nemoclaw)"
return 0
fi

warn "Found nemoclaw at $npm_bin/nemoclaw but could not expose it on PATH."
warn ""
warn "Add one of these directories to your shell profile:"
warn " $NEMOCLAW_SHIM_DIR"
warn " $npm_bin"
warn ""
warn "Continuing — nemoclaw is installed but requires a PATH update."
NEMOCLAW_RECOVERY_PROFILE="$(detect_shell_profile)"
if [[ -x "$NEMOCLAW_SHIM_DIR/nemoclaw" ]]; then
NEMOCLAW_RECOVERY_EXPORT_DIR="$NEMOCLAW_SHIM_DIR"
else
NEMOCLAW_RECOVERY_EXPORT_DIR="$npm_bin"
fi
warn "Found nemoclaw at $npm_bin/nemoclaw but this shell still cannot resolve it."
warn "Onboarding will be skipped until PATH is updated."
return 0
else
warn "Could not locate the nemoclaw executable."
Expand All @@ -547,14 +595,33 @@ verify_nemoclaw() {
# ---------------------------------------------------------------------------
run_onboard() {
info "Running nemoclaw onboard…"
local -a onboard_cmd=(onboard)
if command_exists node && [[ -f "${HOME}/.nemoclaw/onboard-session.json" ]]; then
if node -e '
const fs = require("fs");
const file = process.argv[1];
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
const resumable = data && data.resumable !== false;
const status = data && data.status;
process.exit(resumable && status && status !== "complete" ? 0 : 1);
} catch {
process.exit(1);
}
' "${HOME}/.nemoclaw/onboard-session.json"; then
info "Found an interrupted onboarding session — resuming it."
onboard_cmd+=(--resume)
fi
fi
if [ "${NON_INTERACTIVE:-}" = "1" ]; then
nemoclaw onboard --non-interactive
onboard_cmd+=(--non-interactive)
nemoclaw "${onboard_cmd[@]}"
elif [ -t 0 ]; then
nemoclaw onboard
nemoclaw "${onboard_cmd[@]}"
elif exec 3</dev/tty; then
info "Installer stdin is piped; attaching onboarding to /dev/tty…"
local status=0
nemoclaw onboard <&3 || status=$?
nemoclaw "${onboard_cmd[@]}" <&3 || status=$?
exec 3<&-
return "$status"
else
Expand All @@ -565,30 +632,32 @@ run_onboard() {
# 6. Post-install message (printed last — after onboarding — so PATH hints stay visible)
# ---------------------------------------------------------------------------
post_install_message() {
# Only show shell reload instructions when Node was installed via a
# version manager that modifies PATH in shell profile files.
# nvm and fnm require sourcing the profile; nodesource/brew install to
# system paths already on PATH.
if [[ ! -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]]; then
if [[ "$NEMOCLAW_READY_NOW" == true ]]; then
return 0
fi

local profile="$HOME/.bashrc"
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshrc"
elif [[ ! -f "$HOME/.bashrc" && -f "$HOME/.profile" ]]; then
profile="$HOME/.profile"
if [[ -z "$NEMOCLAW_RECOVERY_EXPORT_DIR" ]]; then
return 0
fi

if [[ -z "$NEMOCLAW_RECOVERY_PROFILE" ]]; then
NEMOCLAW_RECOVERY_PROFILE="$(detect_shell_profile)"
fi

echo ""
echo " ──────────────────────────────────────────────────"
warn "Your current shell may not have the updated PATH."
warn "Your current shell cannot resolve 'nemoclaw' yet."
echo ""
echo " To use nemoclaw now, run:"
echo ""
echo " source $profile"
echo " export PATH=\"${NEMOCLAW_RECOVERY_EXPORT_DIR}:\$PATH\""
echo " source ${NEMOCLAW_RECOVERY_PROFILE}"
echo ""
echo " Then run:"
echo ""
echo " nemoclaw onboard"
echo ""
echo " Or open a new terminal window."
echo " Or open a new terminal window after updating your shell profile."
echo " ──────────────────────────────────────────────────"
echo ""
}
Expand Down Expand Up @@ -635,8 +704,9 @@ main() {
step 3 "Onboarding"
if command_exists nemoclaw; then
run_onboard
ONBOARD_RAN=true
else
warn "Skipping onboarding — nemoclaw is not on PATH. Run 'nemoclaw onboard' after updating your PATH."
warn "Skipping onboarding — this shell still cannot resolve 'nemoclaw'."
fi

print_done
Expand Down
28 changes: 28 additions & 0 deletions scripts/debug.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ elif command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_BIN="gtimeout"
fi

SCRIPT_DIR=""
REPO_ROOT=""
ONBOARD_SESSION_HELPER=""
SCRIPT_PATH="${BASH_SOURCE[0]:-}"
if [ -n "$SCRIPT_PATH" ] && [ -f "$SCRIPT_PATH" ]; then
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ONBOARD_SESSION_HELPER="${REPO_ROOT}/bin/lib/onboard-session.js"
fi

# Redact known sensitive patterns (API keys, tokens, passwords in env/args).
redact() {
sed -E \
Expand Down Expand Up @@ -243,6 +253,24 @@ if [ "$QUICK" = false ]; then
collect "openshell-gateway-info" openshell gateway info
fi

# -- Onboard session state --

section "Onboard Session"
if [ -n "$ONBOARD_SESSION_HELPER" ] && [ -f "$ONBOARD_SESSION_HELPER" ] && command -v node >/dev/null 2>&1; then
# shellcheck disable=SC2016
collect "onboard-session-summary" node -e '
const helper = require(process.argv[1]);
const summary = helper.summarizeForDebug();
if (!summary) {
process.stdout.write("No onboard session state found.\n");
process.exit(0);
}
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
' "$ONBOARD_SESSION_HELPER"
else
echo " (onboard session helper not available, skipping)"
fi

# -- Sandbox internals (via SSH using openshell ssh-config) --

if command -v openshell &>/dev/null \
Expand Down
Loading
Loading