Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
168 changes: 168 additions & 0 deletions bin/lib/backup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Sandbox backup and restore functionality

const fs = require("fs");
const path = require("path");
const os = require("os");
const { execSync } = require("child_process");
const registry = require("./registry");

const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups");

function ensureBackupDir() {
fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 });
}

function listBackups() {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith(".json"));
const backups = [];

for (const file of files) {
try {
const content = JSON.parse(fs.readFileSync(path.join(BACKUP_DIR, file), "utf-8"));
backups.push({
name: content.metadata.name,
createdAt: content.metadata.createdAt,
path: path.join(BACKUP_DIR, file),
size: fs.statSync(path.join(BACKUP_DIR, file)).size,
});
} catch {}
}

return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}

function exportSandbox(sandboxName, outputPath) {
const sandbox = registry.getSandbox(sandboxName);
if (!sandbox) {
console.error(` Sandbox not found: ${sandboxName}`);
return null;
}

ensureBackupDir();

const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const defaultName = `${sandboxName}-${timestamp}.json`;
const backupPath = outputPath || path.join(BACKUP_DIR, defaultName);

let policyContent = "";
try {
policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, {
encoding: "utf-8",
timeout: 10000
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch {
policyContent = "";
}

const backup = {
version: "1.0",
metadata: {
name: sandboxName,
createdAt: new Date().toISOString(),
nemoclawVersion: require("../package.json").version,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
sandbox: {
name: sandbox.name,
model: sandbox.model,
provider: sandbox.provider,
gpuEnabled: sandbox.gpuEnabled,
policies: sandbox.policies || [],
},
policy: policyContent,
};

const dir = path.dirname(backupPath);
if (dir !== BACKUP_DIR) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}

fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), { mode: 0o600 });
console.log(` Exported sandbox '${sandboxName}' to: ${backupPath}`);

return backupPath;
}

function importSandbox(backupPath, newName) {
if (!fs.existsSync(backupPath)) {
console.error(` Backup file not found: ${backupPath}`);
return false;
}

let backup;
try {
backup = JSON.parse(fs.readFileSync(backupPath, "utf-8"));
} catch {
console.error(` Invalid backup file: ${backupPath}`);
return false;
}

if (!backup.version || !backup.sandbox) {
console.error(" Invalid backup format");
return false;
}

const sandboxName = newName || backup.sandbox.name;

console.log(` Creating sandbox '${sandboxName}' from backup...`);

try {
execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"]
});
console.error(` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`);
return false;
} catch {}

console.log(` Note: This only imports the registry config.`);
console.log(` You need to manually recreate the sandbox and apply policies.`);

registry.registerSandbox({
name: sandboxName,
model: backup.sandbox.model,
provider: backup.sandbox.provider,
gpuEnabled: backup.sandbox.gpuEnabled,
policies: backup.sandbox.policies || [],
});
Comment on lines +128 to +150

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

Validate imported sandbox names before persisting them.

Line 128 accepts newName or backup.sandbox.name verbatim, but normal CLI flows already gate sandbox names through validateName(). A malformed backup can therefore register an entry that later nemoclaw <name> ... paths reject. Validate the chosen name before the openshell existence check and before registry.registerSandbox().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/backup.js` around lines 128 - 150, The code uses newName ||
backup.sandbox.name directly for sandboxName and registers it via
registry.registerSandbox without validating; call the existing
validateName(name) function on sandboxName (reject or normalize when invalid)
before running the openshell existence check and before calling
registry.registerSandbox, and handle validation failures by printing an error
and returning false so malformed backup names are not persisted.


if (backup.policy) {
const policyPath = path.join(os.tmpdir(), `nemoclaw-restore-${Date.now()}.yaml`);
fs.writeFileSync(policyPath, backup.policy);
try {
execSync(`openshell policy set --policy "${policyPath}" --wait ${sandboxName}`, {
encoding: "utf-8",
timeout: 30000,
});
console.log(` Restored policy for '${sandboxName}'`);
} catch (err) {
console.warn(` Warning: Could not restore policy: ${err.message}`);
} finally {
fs.unlinkSync(policyPath);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

console.log(` Imported sandbox '${sandboxName}' from backup`);
return true;
}

function deleteBackup(backupPath) {
if (!fs.existsSync(backupPath)) {
console.error(` Backup not found: ${backupPath}`);
return false;
}
fs.unlinkSync(backupPath);
console.log(` Deleted backup: ${backupPath}`);
return true;
}

module.exports = {
BACKUP_DIR,
listBackups,
exportSandbox,
importSandbox,
deleteBackup,
};
167 changes: 165 additions & 2 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ const {
const registry = require("./lib/registry");
const nim = require("./lib/nim");
const policies = require("./lib/policies");
const backup = require("./lib/backup");

// ── Global commands ──────────────────────────────────────────────

const GLOBAL_COMMANDS = new Set([
"onboard", "list", "deploy", "setup", "setup-spark",
"start", "stop", "status", "uninstall",
"start", "stop", "status",
"uninstall",
"backups",
"completion",
"help", "--help", "-h",
]);

Expand Down Expand Up @@ -60,6 +64,12 @@ function exitWithSpawnResult(result) {
process.exit(1);
}

const SANDBOX_ACTIONS = [
"connect", "status", "logs", "policy-add", "policy-list", "destroy", "export"
];

const SHELL_TYPES = ["bash", "zsh", "fish"];

// ── Commands ─────────────────────────────────────────────────────

async function onboard(args) {
Expand Down Expand Up @@ -338,6 +348,136 @@ function sandboxDestroy(sandboxName) {
console.log(` ✓ Sandbox '${sandboxName}' destroyed`);
}

// ── Backup functions ─────────────────────────────────────────────

function listBackups() {
const backups = backup.listBackups();
if (backups.length === 0) {
console.log("");
console.log(" No backups found.");
console.log(` Backups are stored in: ${backup.BACKUP_DIR}`);
console.log("");
return;
}
console.log("");
console.log(" Backups:");
for (const b of backups) {
const size = (b.size / 1024).toFixed(1);
console.log(` ${b.name}`);
console.log(` Created: ${new Date(b.createdAt).toLocaleString()}`);
console.log(` Size: ${size} KB`);
console.log(` Path: ${b.path}`);
console.log("");
}
}

function sandboxExport(sandboxName, outputPath) {
const result = backup.exportSandbox(sandboxName, outputPath);
if (!result) {
process.exit(1);
}
}

function importBackup(backupPath, newName) {
const result = backup.importSandbox(backupPath, newName);
if (!result) {
process.exit(1);
}
}

// ── Shell Completion ─────────────────────────────────────────────

function printCompletion(shell) {
if (!shell || !SHELL_TYPES.includes(shell)) {
console.log(" Usage: nemoclaw completion <shell>");
console.log("");
console.log(" Generate shell completion scripts.");
console.log("");
console.log(" Shells supported: bash, zsh, fish");
console.log("");
console.log(" Example:");
console.log(" # Bash:");
console.log(" nemoclaw completion bash >> ~/.bashrc");
console.log("");
console.log(" # Zsh:");
console.log(" nemoclaw completion zsh >> ~/.zshrc");
console.log("");
console.log(" # Fish:");
console.log(" nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish");
return;
Comment on lines +458 to +474

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

Return a failure status for unsupported shells.

printCompletion() prints usage and returns success here. nemoclaw completion foo > ~/.bashrc will therefore look successful and can write the usage text into a shell config file. Please send this path through stderr and exit non-zero.

💡 Suggested fix
 function printCompletion(shell) {
   if (!shell || !SHELL_TYPES.includes(shell)) {
-    console.log("  Usage: nemoclaw completion <shell>");
-    console.log("");
-    console.log("  Generate shell completion scripts.");
-    console.log("");
-    console.log("  Shells supported: bash, zsh, fish");
-    console.log("");
-    console.log("  Example:");
-    console.log("    # Bash:");
-    console.log("    nemoclaw completion bash >> ~/.bashrc");
-    console.log("");
-    console.log("    # Zsh:");
-    console.log("    nemoclaw completion zsh >> ~/.zshrc");
-    console.log("");
-    console.log("    # Fish:");
-    console.log("    nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish");
-    return;
+    console.error("  Usage: nemoclaw completion <shell>");
+    console.error("");
+    console.error("  Generate shell completion scripts.");
+    console.error("");
+    console.error("  Shells supported: bash, zsh, fish");
+    console.error("");
+    console.error("  Example:");
+    console.error("    # Bash:");
+    console.error("    nemoclaw completion bash >> ~/.bashrc");
+    console.error("");
+    console.error("    # Zsh:");
+    console.error("    nemoclaw completion zsh >> ~/.zshrc");
+    console.error("");
+    console.error("    # Fish:");
+    console.error("    nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish");
+    process.exit(1);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/nemoclaw.js` around lines 441 - 457, The usage block for unsupported
shells currently prints to stdout and returns success; change it to emit the
usage/diagnostic to stderr (use console.error or process.stderr.write) and then
exit with a non-zero status (e.g., process.exit(1)) when shell is missing or not
in SHELL_TYPES; update the code path around the check that references shell and
SHELL_TYPES (and the printCompletion()/completion handling) so that failing
cases send output to stderr and call process.exit(1) instead of returning
normally.

}

const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let sandboxNames = [];
try {
sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name);
} catch {
sandboxNames = [];
}

const sandboxNamesStr = sandboxNames.length > 0 ? sandboxNames.join(" ") : "";
const sandboxNamesZsh = sandboxNames.length > 0 ? sandboxNames.map(s => `"${s}"`).join(" ") : "";
const sandboxNamesFish = sandboxNames.length > 0 ? sandboxNames.map(s => `'${s}'`).join(" ") : "";
Comment on lines +465 to +487

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

Sandbox name completion is still a snapshot.

sandboxNames is resolved once in Node and then inlined into the generated script. Since Line 450 through Line 456 tell users to append that output once to their shell config, completions go stale after any later onboard, import, or destroy. The emitted completion function needs a runtime lookup for current sandbox names instead of baking them into the file.


if (shell === "bash") {
console.log(`_nemoclaw_completions() {
local cur prev opts
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"

# Global commands
opts="${globalCmds.join(" ")}"

# Sandbox names (if previous word is a known sandbox)
if [[ " ${sandboxNamesStr} " =~ " $prev " ]]; then
opts="${SANDBOX_ACTIONS.join(" ")}"
fi

# Also add sandbox names as possible first argument
opts="$opts ${sandboxNamesStr}"

COMPREPLY=(\$(compgen -W "\$opts" -- \$cur))
return 0
}

complete -F _nemoclaw_completions nemoclaw`);
} else if (shell === "zsh") {
console.log(`# nemoclaw zsh completion

local -a global_cmds
global_cmds=(${globalCmds.map(c => `"${c}"`).join(" ")})

local -a sandbox_actions
sandbox_actions=(${SANDBOX_ACTIONS.map(a => `"${a}"`).join(" ")})

local -a sandbox_names
sandbox_names=(${sandboxNamesZsh})

_nemoclaw() {
local -a cmd
cmd=(\${words[1,CURRENT-1]})

# Check if first word is a sandbox name
if [[ " \${sandbox_names[@]} " =~ " \${cmd[1]} " ]]; then
_describe 'sandbox actions' sandbox_actions
else
_describe 'commands' global_cmds
_describe 'sandboxes' sandbox_names
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

compdef _nemoclaw nemoclaw`);
} else if (shell === "fish") {
console.log(`# nemoclaw fish completion

complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1"

complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc | head -1) ${sandboxNamesFish}"
`);
Comment on lines +489 to +544

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

🧩 Analysis chain

🏁 Script executed:

cat -n bin/nemoclaw.js | sed -n '472,527p'

Repository: NVIDIA/NemoClaw

Length of output: 2033


🏁 Script executed:

rg -n "globalCmds|SANDBOX_ACTIONS" bin/nemoclaw.js -B 2 -A 2

Repository: NVIDIA/NemoClaw

Length of output: 1179


🏁 Script executed:

rg -n "import|export" bin/nemoclaw.js | head -30

Repository: NVIDIA/NemoClaw

Length of output: 914


Add path completion for import and export argument positions.

The completion scripts only return command/action word lists and suppress path completion for import <path> and <sandbox> export [path] arguments. In bash and zsh, these positions only complete against the configured word lists; in fish, the -f flag explicitly disables file completion. Users cannot tab-complete filesystem paths for backup files or export destinations.

Add conditional branches or fallback handlers to enable path completion after these commands. Quick validation: nemoclaw import ./<TAB> and nemoclaw my-sandbox export ./<TAB> should expand filesystem paths in bash, zsh, and fish.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/nemoclaw.js` around lines 472 - 527, The completion scripts currently
only offer word lists; update the bash function _nemoclaw_completions, the zsh
function _nemoclaw, and the fish complete calls to detect when the current
position is an import path (previous token == "import") or a sandbox export path
(first token is a sandbox name and the next token == "export") and fall back to
file/path completion for those positions: in bash (inside _nemoclaw_completions)
check if prev == "import" or (COMP_WORDS[1] in sandboxNamesStr and prev ==
"export") and then generate COMPREPLY using compgen -f -- "$cur" instead of the
word list; in zsh (inside _nemoclaw) detect the same conditions using ${words}
and call _files or _path_files for path completion rather than _describe; in
fish add separate complete entries for the import and sandbox export argument
positions without the -f flag (or with appropriate -f/-a to allow file
completion) so filesystem paths are offered for those argument slots while
leaving other completions unchanged (use globalCmds, SANDBOX_ACTIONS,
sandboxNamesStr/sandboxNamesZsh/sandboxNamesFish to identify commands and
sandbox names).

}
}

// ── Help ─────────────────────────────────────────────────────────

function help() {
Expand All @@ -355,6 +495,11 @@ function help() {
nemoclaw <name> status Show sandbox status and health
nemoclaw <name> logs [--follow] View sandbox logs
nemoclaw <name> destroy Stop NIM + delete sandbox
nemoclaw <name> export [path] Export sandbox backup

Backup & Restore:
nemoclaw backups List all backups
nemoclaw import <path> [name] Import a sandbox from backup

Policy Presets:
nemoclaw <name> policy-add Add a policy preset to a sandbox
Expand All @@ -374,6 +519,9 @@ function help() {
--keep-openshell Leave the openshell binary installed
--delete-models Remove NemoClaw-pulled Ollama models

Shell Completion:
nemoclaw completion <shell> Generate shell completion script

Credentials are prompted on first use, then saved securely
in ~/.nemoclaw/credentials.json (mode 600).
`);
Expand Down Expand Up @@ -402,11 +550,25 @@ const [cmd, ...args] = process.argv.slice(2);
case "status": showStatus(); break;
case "uninstall": uninstall(args); break;
case "list": listSandboxes(); break;
case "backups": listBackups(); break;
case "completion": printCompletion(args[0]); break;
default: help(); break;
}
return;
}

// Import command (special case - not a sandbox name)
if (cmd === "import") {
const backupPath = args[0];
const newName = args[1];
if (!backupPath) {
console.error(" Usage: nemoclaw import <backup-file> [new-sandbox-name]");
process.exit(1);
}
importBackup(backupPath, newName);
return;
}

// Sandbox-scoped commands: nemoclaw <name> <action>
const sandbox = registry.getSandbox(cmd);
if (sandbox) {
Expand All @@ -420,9 +582,10 @@ const [cmd, ...args] = process.argv.slice(2);
case "policy-add": await sandboxPolicyAdd(cmd); break;
case "policy-list": sandboxPolicyList(cmd); break;
case "destroy": sandboxDestroy(cmd); break;
case "export": sandboxExport(cmd, actionArgs[0]); break;
default:
console.error(` Unknown action: ${action}`);
console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`);
console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy, export`);
process.exit(1);
}
return;
Expand Down