From fca261bf6c9196689d638b801e588f22efdf74ef Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 20 Mar 2026 07:28:08 -0700 Subject: [PATCH 1/4] fix: remove openclaw nemoclaw CLI commands, keep provider and slash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #487, #489. The `openclaw nemoclaw` CLI subcommands have been removed. The NemoClaw host CLI (`nemoclaw`) is the only supported CLI interface. The OpenClaw plugin still registers an inference provider and /nemoclaw slash command inside the sandbox — only the CLI commands are removed. - Delete cli.ts and 8 command files (launch, status, logs, connect, migrate, eject, onboard) - Delete dead code (blueprint/exec, fetch, resolve, verify; onboard/prompt, validate) - Keep slash.ts, migration-state.ts, blueprint/state.ts, onboard/config.ts - Remove registerCli() from index.ts - Update slash command help text to reference nemoclaw host CLI - Remove plugin commands section from README and docs - Update all openclaw nemoclaw references across 7 doc files - Update e2e test assertions for removed dist files --- README.md | 16 +- docs/about/how-it-works.md | 4 +- docs/inference/switch-inference-providers.md | 4 +- docs/monitoring/monitor-sandbox-activity.md | 18 +- .../customize-network-policy.md | 2 +- docs/reference/architecture.md | 2 +- docs/reference/commands.md | 72 +-- docs/reference/troubleshooting.md | 6 +- .../policies/openclaw-sandbox.yaml | 2 +- nemoclaw/src/blueprint/exec.ts | 98 ---- nemoclaw/src/blueprint/fetch.ts | 61 -- nemoclaw/src/blueprint/resolve.ts | 82 --- nemoclaw/src/blueprint/verify.ts | 95 ---- nemoclaw/src/cli.ts | 136 ----- nemoclaw/src/commands/connect.ts | 39 -- nemoclaw/src/commands/eject.ts | 94 ---- nemoclaw/src/commands/launch.ts | 142 ----- nemoclaw/src/commands/logs.ts | 72 --- nemoclaw/src/commands/migrate.ts | 296 ---------- nemoclaw/src/commands/onboard.ts | 523 ------------------ nemoclaw/src/commands/slash.ts | 24 +- nemoclaw/src/commands/status.test.ts | 456 --------------- nemoclaw/src/commands/status.ts | 179 ------ nemoclaw/src/index.ts | 26 +- nemoclaw/src/onboard/prompt.ts | 72 --- nemoclaw/src/onboard/validate.ts | 58 -- test/e2e-test.sh | 10 +- 27 files changed, 33 insertions(+), 2556 deletions(-) delete mode 100644 nemoclaw/src/blueprint/exec.ts delete mode 100644 nemoclaw/src/blueprint/fetch.ts delete mode 100644 nemoclaw/src/blueprint/resolve.ts delete mode 100644 nemoclaw/src/blueprint/verify.ts delete mode 100644 nemoclaw/src/cli.ts delete mode 100644 nemoclaw/src/commands/connect.ts delete mode 100644 nemoclaw/src/commands/eject.ts delete mode 100644 nemoclaw/src/commands/launch.ts delete mode 100644 nemoclaw/src/commands/logs.ts delete mode 100644 nemoclaw/src/commands/migrate.ts delete mode 100644 nemoclaw/src/commands/onboard.ts delete mode 100644 nemoclaw/src/commands/status.test.ts delete mode 100644 nemoclaw/src/commands/status.ts delete mode 100644 nemoclaw/src/onboard/prompt.ts delete mode 100644 nemoclaw/src/onboard/validate.ts diff --git a/README.md b/README.md index e828e50d0ab..259ca88a81e 100644 --- a/README.md +++ b/README.md @@ -219,21 +219,7 @@ Run these on the host to set up, connect to, and manage sandboxes. | `openshell term` | Launch the OpenShell TUI for monitoring and approvals. | | `nemoclaw start` / `stop` / `status` | Manage auxiliary services (Telegram bridge, tunnel). | -### Plugin commands (`openclaw nemoclaw`) - -Run these inside the OpenClaw CLI. These commands are under active development and may not all be functional yet. - -| Command | Description | -|--------------------------------------------|----------------------------------------------------------| -| `openclaw nemoclaw launch [--profile ...]` | Bootstrap OpenClaw inside an OpenShell sandbox. | -| `openclaw nemoclaw status` | Show sandbox health, blueprint state, and inference. | -| `openclaw nemoclaw logs [-f]` | Stream blueprint execution and sandbox logs. | - -See the full [CLI reference](https://docs.nvidia.com/nemoclaw/latest/reference/commands.html) for all commands, flags, and options. - -> **Known limitations:** -> - The `openclaw nemoclaw` plugin commands are under active development. Use the `nemoclaw` host CLI as the primary interface. -> - Setup may require manual workarounds on some platforms. File an issue if you encounter blockers. +See the full [CLI reference](https://docs.nvidia.com/nemoclaw/latest/reference/commands.md) for all commands, flags, and options. --- diff --git a/docs/about/how-it-works.md b/docs/about/how-it-works.md index 9a873a904c7..5ff0365081b 100644 --- a/docs/about/how-it-works.md +++ b/docs/about/how-it-works.md @@ -75,7 +75,7 @@ Thin plugin, versioned blueprint : The plugin stays small and stable. Orchestration logic lives in the blueprint and evolves on its own release cadence. Respect CLI boundaries -: The `nemoclaw` CLI is the primary interface. Plugin commands are available under `openclaw nemoclaw` but do not override built-in OpenClaw commands. +: The `nemoclaw` CLI is the primary interface for sandbox management. Supply chain safety : Blueprint artifacts are immutable, versioned, and digest-verified before execution. @@ -91,7 +91,7 @@ Reproducible setup NemoClaw is split into two parts: -- The *plugin* is a TypeScript package that powers the `nemoclaw` CLI and also registers commands under `openclaw nemoclaw`. +- The *plugin* is a TypeScript package that registers an inference provider and the `/nemoclaw` slash command inside the sandbox. It handles user interaction and delegates orchestration work to the blueprint. - The *blueprint* is a versioned Python artifact that contains all the logic for creating sandboxes, applying policies, and configuring inference. The plugin resolves, verifies, and executes the blueprint as a subprocess. diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index c750c72d76f..582c7bf134e 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -44,13 +44,13 @@ The `nemoclaw onboard` command stores this key in `~/.nemoclaw/credentials.json` Run the status command to confirm the change: ```console -$ openclaw nemoclaw status +$ nemoclaw status ``` Add the `--json` flag for machine-readable output: ```console -$ openclaw nemoclaw status --json +$ nemoclaw status --json ``` The output includes the active provider, model, and endpoint. diff --git a/docs/monitoring/monitor-sandbox-activity.md b/docs/monitoring/monitor-sandbox-activity.md index ab071e5f3ed..3705e046c44 100644 --- a/docs/monitoring/monitor-sandbox-activity.md +++ b/docs/monitoring/monitor-sandbox-activity.md @@ -32,13 +32,13 @@ Use the NemoClaw status, logs, and TUI tools together to inspect sandbox health, Run the status command to view the sandbox state, blueprint run information, and active inference configuration: ```console -$ openclaw nemoclaw status +$ nemoclaw status ``` For machine-readable output, add the `--json` flag: ```console -$ openclaw nemoclaw status --json +$ nemoclaw status ``` Key fields in the output include the following: @@ -47,32 +47,32 @@ Key fields in the output include the following: - Blueprint run ID, which is the identifier for the most recent blueprint execution. - Inference provider, which shows the active provider, model, and endpoint. -If you run `openclaw nemoclaw status` from inside the sandbox, the command detects the sandbox context and reports it. Host-level sandbox and inference details are not available from within the sandbox. Run `openshell sandbox list` on the host to check the underlying sandbox state. +Run `nemoclaw status` on the host to check sandbox state. Use `openshell sandbox list` for the underlying sandbox details. ## View Blueprint and Sandbox Logs Stream the most recent log output from the blueprint runner and sandbox: ```console -$ openclaw nemoclaw logs +$ nemoclaw logs ``` To follow the log output in real time: ```console -$ openclaw nemoclaw logs -f +$ nemoclaw logs -f ``` To display a specific number of log lines: ```console -$ openclaw nemoclaw logs -n 100 +$ nemoclaw logs ``` To view logs for a specific blueprint run instead of the most recent one: ```console -$ openclaw nemoclaw logs --run-id +$ nemoclaw logs ``` ## Monitor Network Activity in the TUI @@ -104,8 +104,8 @@ $ openclaw agent --agent main --local -m "Test inference" --session-id debug If the request fails, check the following: -1. Run `openclaw nemoclaw status` to confirm the active provider and endpoint. -2. Run `openclaw nemoclaw logs -f` to view error messages from the blueprint runner. +1. Run `nemoclaw status` to confirm the active provider and endpoint. +2. Run `nemoclaw logs -f` to view error messages from the blueprint runner. 3. Verify that the inference endpoint is reachable from the host. ## Related Topics diff --git a/docs/network-policy/customize-network-policy.md b/docs/network-policy/customize-network-policy.md index 8cbe8ec5b67..84962bda347 100644 --- a/docs/network-policy/customize-network-policy.md +++ b/docs/network-policy/customize-network-policy.md @@ -62,7 +62,7 @@ The wizard picks up the modified policy file and applies it to the sandbox. Check that the sandbox is running with the updated policy: ```console -$ openclaw nemoclaw status +$ nemoclaw status ``` ## Dynamic Changes diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index b5bd02d9c5f..f5dc543110c 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -24,7 +24,7 @@ NemoClaw has two main components: a TypeScript plugin that integrates with the O ## NemoClaw Plugin -The plugin is a thin TypeScript package that registers commands under `openclaw nemoclaw`. +The plugin is a thin TypeScript package that registers an inference provider and the `/nemoclaw` slash command. It runs in-process with the OpenClaw gateway and handles user-facing CLI interactions. ```text diff --git a/docs/reference/commands.md b/docs/reference/commands.md index bb65f775b02..fd6bd7dfce8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -20,77 +20,7 @@ status: published # Commands -NemoClaw provides two command interfaces. -The plugin commands run under the `openclaw nemoclaw` namespace inside the OpenClaw CLI. -The standalone `nemoclaw` binary handles host-side setup, deployment, and service management. -Both interfaces are installed when you run `npm install -g nemoclaw`. - -## Plugin Commands - -### `openclaw nemoclaw launch` - -Bootstrap OpenClaw inside an OpenShell sandbox. -If NemoClaw detects an existing host installation, `launch` stops unless you pass `--force`. - -```console -$ openclaw nemoclaw launch [--force] [--profile ] -``` - -`--force` -: Skip the ergonomics warning and force plugin-driven bootstrap. Without this flag, - NemoClaw recommends using `openshell sandbox create` directly for new installs. - -`--profile ` -: Blueprint profile to use. Default: `default`. - -### `nemoclaw connect` - -Open an interactive shell inside the OpenClaw sandbox. -Use this after launch to connect and chat with the agent through the TUI or CLI. - -```console -$ nemoclaw my-assistant connect -``` - -If the TUI view is not a good fit for very long responses, use the CLI form instead: - -```console -$ openclaw agent --agent main --local -m "" --session-id -``` - -This is the recommended workaround when you need the full response printed directly in the terminal. - -### `openclaw nemoclaw status` - -Display sandbox health, blueprint run state, and inference configuration. - -```console -$ openclaw nemoclaw status [--json] -``` - -`--json` -: Output as JSON for programmatic consumption. - -When running inside an active OpenShell sandbox, the status command detects the sandbox context and reports "active (inside sandbox)" instead of false negatives. -Host-side sandbox state and inference configuration are not inspectable from inside the sandbox. -Run `openshell sandbox list` on the host to check the underlying sandbox state. - -### `openclaw nemoclaw logs` - -Stream blueprint execution and sandbox logs. - -```console -$ openclaw nemoclaw logs [-f] [-n ] [--run-id ] -``` - -`-f, --follow` -: Follow log output, similar to `tail -f`. - -`-n, --lines ` -: Number of lines to show. Default: `50`. - -`--run-id ` -: Show logs for a specific blueprint run instead of the latest. +The `nemoclaw` CLI is the primary interface for managing NemoClaw sandboxes. It is installed when you run `npm install -g nemoclaw`. ### `/nemoclaw` Slash Command diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 14ec2c27e18..40daf06f6f9 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -151,7 +151,7 @@ Run `nemoclaw onboard` to recreate the sandbox from the same blueprint and polic ### Status shows "not running" inside the sandbox This is expected behavior. -When running `openclaw nemoclaw status` inside an active sandbox, host-side sandbox state and inference configuration are not inspectable. +When checking status inside an active sandbox, host-side sandbox state and inference configuration are not inspectable. The status command detects the sandbox context and reports "active (inside sandbox)" instead. Run `openshell sandbox list` on the host to check the underlying sandbox state. @@ -162,7 +162,7 @@ Verify that the inference provider endpoint is reachable from the host. Check the active provider and endpoint: ```console -$ openclaw nemoclaw status +$ nemoclaw status ``` If the endpoint is correct but requests still fail, check for network policy rules that may block the connection, and verify that your NVIDIA API key is valid. @@ -184,7 +184,7 @@ Refer to [Customize the Network Policy](../network-policy/customize-network-poli View the error output for the failed blueprint run: ```console -$ openclaw nemoclaw logs --run-id +$ nemoclaw logs ``` If the run ID is unknown, omit `--run-id` to view logs from the most recent run. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 153630b3193..b176e6fde1a 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -10,7 +10,7 @@ # strict — this file. Minimum for onboard + basic agent operation. # relaxed — adds third-party model providers, broader web access. # -# To add endpoints: update this file and re-run `openclaw nemoclaw migrate` +# To add endpoints: update this file and re-run `nemoclaw onboard` # or apply dynamically via `openshell policy set`. version: 1 diff --git a/nemoclaw/src/blueprint/exec.ts b/nemoclaw/src/blueprint/exec.ts deleted file mode 100644 index b3e57680b7b..00000000000 --- a/nemoclaw/src/blueprint/exec.ts +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import type { PluginLogger } from "../index.js"; - -export type BlueprintAction = "plan" | "apply" | "status" | "rollback"; - -export interface BlueprintRunOptions { - blueprintPath: string; - action: BlueprintAction; - profile: string; - planPath?: string; - runId?: string; - jsonOutput?: boolean; - dryRun?: boolean; - endpointUrl?: string; -} - -export interface BlueprintRunResult { - success: boolean; - runId: string; - action: BlueprintAction; - output: string; - exitCode: number; -} - -function failResult(action: BlueprintAction, message: string): BlueprintRunResult { - return { success: false, runId: "error", action, output: message, exitCode: 1 }; -} - -export async function execBlueprint( - options: BlueprintRunOptions, - logger: PluginLogger, -): Promise { - const runnerPath = join(options.blueprintPath, "orchestrator", "runner.py"); - - if (!existsSync(runnerPath)) { - const msg = `Blueprint runner not found at ${runnerPath}. Is the blueprint installed correctly?`; - logger.error(msg); - return failResult(options.action, msg); - } - - const args: string[] = [runnerPath, options.action, "--profile", options.profile]; - - if (options.jsonOutput) args.push("--json"); - if (options.planPath) args.push("--plan", options.planPath); - if (options.runId) args.push("--run-id", options.runId); - if (options.dryRun) args.push("--dry-run"); - if (options.endpointUrl) args.push("--endpoint-url", options.endpointUrl); - - logger.info(`Running blueprint: ${options.action} (profile: ${options.profile})`); - - return new Promise((resolve) => { - const chunks: string[] = []; - const proc = spawn("python3", args, { - cwd: options.blueprintPath, - env: { - ...process.env, - NEMOCLAW_BLUEPRINT_PATH: options.blueprintPath, - NEMOCLAW_ACTION: options.action, - }, - stdio: ["pipe", "pipe", "pipe"], - }); - - proc.stdout.on("data", (data: Buffer) => { - const line = data.toString(); - chunks.push(line); - }); - - proc.stderr.on("data", (data: Buffer) => { - const line = data.toString().trim(); - if (line) logger.warn(line); - }); - - proc.on("close", (code) => { - const output = chunks.join(""); - const runIdMatch = output.match(/^RUN_ID:(.+)$/m); - resolve({ - success: code === 0, - runId: runIdMatch?.[1] ?? "unknown", - action: options.action, - output, - exitCode: code ?? 1, - }); - }); - - proc.on("error", (err) => { - const msg = err.message.includes("ENOENT") - ? "python3 not found. The blueprint runner requires Python 3.11+." - : `Failed to start blueprint runner: ${err.message}`; - logger.error(msg); - resolve(failResult(options.action, msg)); - }); - }); -} diff --git a/nemoclaw/src/blueprint/fetch.ts b/nemoclaw/src/blueprint/fetch.ts deleted file mode 100644 index 03a98924132..00000000000 --- a/nemoclaw/src/blueprint/fetch.ts +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Blueprint artifact fetching — download versioned blueprint archives from - * an OCI registry or GitHub release, extract to local cache. - */ - -import type { BlueprintManifest, ResolvedBlueprint } from "./resolve.js"; -import { getCacheDir, getCachedBlueprintPath, readCachedManifest } from "./resolve.js"; - -/** - * Fetch a blueprint artifact from a remote registry and cache it locally. - * - * Intended flow: - * 1. Resolve "latest" to a concrete version tag via registry API - * 2. Download the blueprint tarball from the OCI registry or GitHub release - * 3. Verify digest (SHA-256) against the registry manifest - * 4. Check compatibility metadata (min OpenShell/OpenClaw versions) - * 5. Extract to local cache dir - * 6. Return resolved blueprint - * - * For now, blueprints must be placed manually in the cache directory. - */ -export function fetchBlueprint(registry: string, version: string): Promise { - return Promise.reject( - new Error( - `Blueprint fetch not yet implemented. ` + - `Registry: ${registry}, Version: ${version}. ` + - `Place blueprint files in ${getCacheDir()}// for local development.`, - ), - ); -} - -/** - * Resolve a "latest" version tag to a concrete version string by querying - * the registry's tag list or release API. - */ -export async function resolveLatestVersion(registry: string): Promise { - // Future: query OCI tag list or GitHub releases API - void registry; - throw new Error("Latest version resolution not yet implemented."); -} - -/** - * Download and extract a blueprint tarball into the local cache directory. - * Returns the local path where the blueprint was extracted. - */ -export async function downloadAndCache( - registry: string, - version: string, -): Promise<{ localPath: string; manifest: BlueprintManifest }> { - // Future: HTTP fetch + tar extract + manifest parse - void registry; - const localPath = getCachedBlueprintPath(version); - const manifest = readCachedManifest(version); - if (!manifest) { - throw new Error(`Failed to read manifest after download for version ${version}`); - } - return { localPath, manifest }; -} diff --git a/nemoclaw/src/blueprint/resolve.ts b/nemoclaw/src/blueprint/resolve.ts deleted file mode 100644 index 6de02ccd472..00000000000 --- a/nemoclaw/src/blueprint/resolve.ts +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { NemoClawConfig } from "../index.js"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fetchBlueprint } from "./fetch.js"; - -export interface BlueprintManifest { - version: string; - minOpenShellVersion: string; - minOpenClawVersion: string; - profiles: string[]; - digest: string; -} - -export interface ResolvedBlueprint { - version: string; - localPath: string; - manifest: BlueprintManifest; - cached: boolean; -} - -const CACHE_DIR = join(process.env.HOME ?? "/tmp", ".nemoclaw", "blueprints"); - -export function getCacheDir(): string { - return CACHE_DIR; -} - -export function getCachedBlueprintPath(version: string): string { - return join(CACHE_DIR, version); -} - -export function isCached(version: string): boolean { - const manifestPath = join(getCachedBlueprintPath(version), "blueprint.yaml"); - return existsSync(manifestPath); -} - -export function readCachedManifest(version: string): BlueprintManifest | null { - const manifestPath = join(getCachedBlueprintPath(version), "blueprint.yaml"); - if (!existsSync(manifestPath)) return null; - const raw = readFileSync(manifestPath, "utf-8"); - // Minimal YAML parsing for the manifest header - return parseManifestHeader(raw); -} - -function parseManifestHeader(raw: string): BlueprintManifest { - const get = (key: string): string => { - const match = raw.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); - return match?.[1]?.trim() ?? ""; - }; - const profiles = get("profiles"); - return { - version: get("version"), - minOpenShellVersion: get("min_openshell_version"), - minOpenClawVersion: get("min_openclaw_version"), - profiles: profiles ? profiles.split(",").map((p) => p.trim()) : ["default"], - digest: get("digest"), - }; -} - -export async function resolveBlueprint(config: NemoClawConfig): Promise { - const version = config.blueprintVersion; - - // Check local cache first - if (version !== "latest" && isCached(version)) { - const manifest = readCachedManifest(version); - if (manifest) { - return { - version, - localPath: getCachedBlueprintPath(version), - manifest, - cached: true, - }; - } - } - - // Fetch from registry - return fetchBlueprint(config.blueprintRegistry, version); -} - -// fetchBlueprint is imported from ./fetch.ts diff --git a/nemoclaw/src/blueprint/verify.ts b/nemoclaw/src/blueprint/verify.ts deleted file mode 100644 index bd94b71ffeb..00000000000 --- a/nemoclaw/src/blueprint/verify.ts +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createHash } from "node:crypto"; -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import type { BlueprintManifest } from "./resolve.js"; - -export interface VerificationResult { - valid: boolean; - expectedDigest: string; - actualDigest: string; - errors: string[]; -} - -export function verifyBlueprintDigest( - blueprintPath: string, - manifest: BlueprintManifest, -): VerificationResult { - const errors: string[] = []; - const actualDigest = computeDirectoryDigest(blueprintPath); - - if (manifest.digest && manifest.digest !== actualDigest) { - errors.push(`Digest mismatch: expected ${manifest.digest}, got ${actualDigest}`); - } - - return { - valid: errors.length === 0, - expectedDigest: manifest.digest, - actualDigest, - errors, - }; -} - -export function checkCompatibility( - manifest: BlueprintManifest, - openshellVersion: string, - openclawVersion: string, -): string[] { - const errors: string[] = []; - - if ( - manifest.minOpenShellVersion && - !satisfiesMinVersion(openshellVersion, manifest.minOpenShellVersion) - ) { - errors.push(`OpenShell version ${openshellVersion} < required ${manifest.minOpenShellVersion}`); - } - - if ( - manifest.minOpenClawVersion && - !satisfiesMinVersion(openclawVersion, manifest.minOpenClawVersion) - ) { - errors.push(`OpenClaw version ${openclawVersion} < required ${manifest.minOpenClawVersion}`); - } - - return errors; -} - -function satisfiesMinVersion(actual: string, minimum: string): boolean { - const aParts = actual.split(".").map(Number); - const mParts = minimum.split(".").map(Number); - for (let i = 0; i < Math.max(aParts.length, mParts.length); i++) { - const a = aParts[i] ?? 0; - const m = mParts[i] ?? 0; - if (a > m) return true; - if (a < m) return false; - } - return true; // equal -} - -function computeDirectoryDigest(dirPath: string): string { - const hash = createHash("sha256"); - const files = collectFiles(dirPath).sort(); - for (const file of files) { - hash.update(file); // include relative path - hash.update(readFileSync(join(dirPath, file))); - } - return hash.digest("hex"); -} - -function collectFiles(dirPath: string, prefix = ""): string[] { - const entries = readdirSync(dirPath); - const files: string[] = []; - for (const entry of entries) { - const fullPath = join(dirPath, entry); - const relativePath = prefix ? `${prefix}/${entry}` : entry; - const stat = statSync(fullPath); - if (stat.isDirectory()) { - files.push(...collectFiles(fullPath, relativePath)); - } else { - files.push(relativePath); - } - } - return files; -} diff --git a/nemoclaw/src/cli.ts b/nemoclaw/src/cli.ts deleted file mode 100644 index 336dc2c3475..00000000000 --- a/nemoclaw/src/cli.ts +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * CLI registrar for `openclaw nemoclaw `. - * - * Wires commander.js subcommands to the existing blueprint infrastructure. - */ - -import type { OpenClawPluginApi, PluginCliContext } from "./index.js"; -import { getPluginConfig } from "./index.js"; -import { cliStatus } from "./commands/status.js"; -import { cliMigrate } from "./commands/migrate.js"; -import { cliLaunch } from "./commands/launch.js"; -import { cliConnect } from "./commands/connect.js"; -import { cliEject } from "./commands/eject.js"; -import { cliLogs } from "./commands/logs.js"; -import { cliOnboard } from "./commands/onboard.js"; - -export function registerCliCommands(ctx: PluginCliContext, api: OpenClawPluginApi): void { - const { program, logger } = ctx; - const pluginConfig = getPluginConfig(api); - - const nemoclaw = program.command("nemoclaw").description("NemoClaw sandbox management"); - - // openclaw nemoclaw status - nemoclaw - .command("status") - .description("Show sandbox, blueprint, and inference state") - .option("--json", "Output as JSON", false) - .action(async (opts: { json: boolean }) => { - await cliStatus({ json: opts.json, logger, pluginConfig }); - }); - - // openclaw nemoclaw migrate - nemoclaw - .command("migrate") - .description("Migrate host OpenClaw installation into an OpenShell sandbox") - .option("--dry-run", "Show what would be migrated without making changes", false) - .option("--profile ", "Blueprint profile to use", "default") - .option("--skip-backup", "Skip creating a host backup snapshot", false) - .action(async (opts: { dryRun: boolean; profile: string; skipBackup: boolean }) => { - await cliMigrate({ - dryRun: opts.dryRun, - profile: opts.profile, - skipBackup: opts.skipBackup, - logger, - pluginConfig, - }); - }); - - // openclaw nemoclaw launch - nemoclaw - .command("launch") - .description("Fresh setup: bootstrap OpenClaw inside OpenShell") - .option("--force", "Skip ergonomics warning and force plugin-driven bootstrap", false) - .option("--profile ", "Blueprint profile to use", "default") - .action(async (opts: { force: boolean; profile: string }) => { - await cliLaunch({ - force: opts.force, - profile: opts.profile, - logger, - pluginConfig, - }); - }); - - // openclaw nemoclaw connect - nemoclaw - .command("connect") - .description("Open an interactive shell inside the OpenClaw sandbox") - .option("--sandbox ", "Sandbox name to connect to", pluginConfig.sandboxName) - .action(async (opts: { sandbox: string }) => { - await cliConnect({ sandbox: opts.sandbox, logger }); - }); - - // openclaw nemoclaw logs - nemoclaw - .command("logs") - .description("Stream blueprint execution and sandbox logs") - .option("-f, --follow", "Follow log output", false) - .option("-n, --lines ", "Number of lines to show", "50") - .option("--run-id ", "Show logs for a specific blueprint run") - .action(async (opts: { follow: boolean; lines: string; runId?: string }) => { - await cliLogs({ - follow: opts.follow, - lines: parseInt(opts.lines, 10), - runId: opts.runId, - logger, - pluginConfig, - }); - }); - - // openclaw nemoclaw eject - nemoclaw - .command("eject") - .description("Rollback from OpenShell and restore host installation") - .option("--run-id ", "Specific blueprint run ID to rollback from") - .option("--confirm", "Skip confirmation prompt", false) - .action(async (opts: { runId?: string; confirm: boolean }) => { - await cliEject({ - runId: opts.runId, - confirm: opts.confirm, - logger, - pluginConfig, - }); - }); - - // openclaw nemoclaw onboard - nemoclaw - .command("onboard") - .description("Interactive setup: configure inference endpoint, credential, and model") - .option("--api-key ", "API key for endpoints that require one (skips prompt)") - .option("--endpoint ", "Endpoint type: build, ncp, ollama, nim-local, vllm, custom (nim-local and vllm are experimental)") - .option("--ncp-partner ", "NCP partner name (when endpoint is ncp)") - .option("--endpoint-url ", "Endpoint URL (for ncp, nim-local, ollama, or custom)") - .option("--model ", "Model ID to use") - .action( - async (opts: { - apiKey?: string; - endpoint?: string; - ncpPartner?: string; - endpointUrl?: string; - model?: string; - }) => { - await cliOnboard({ - apiKey: opts.apiKey, - endpoint: opts.endpoint, - ncpPartner: opts.ncpPartner, - endpointUrl: opts.endpointUrl, - model: opts.model, - logger, - pluginConfig, - }); - }, - ); -} diff --git a/nemoclaw/src/commands/connect.ts b/nemoclaw/src/commands/connect.ts deleted file mode 100644 index bb37b916702..00000000000 --- a/nemoclaw/src/commands/connect.ts +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawn } from "node:child_process"; -import type { PluginLogger } from "../index.js"; - -export interface ConnectOptions { - sandbox: string; - logger: PluginLogger; -} - -export async function cliConnect(opts: ConnectOptions): Promise { - const { sandbox: sandboxName, logger } = opts; - - logger.info(`Connecting to OpenClaw sandbox: ${sandboxName}`); - logger.info("You will be inside the sandbox. Run 'openclaw' commands normally."); - logger.info("Type 'exit' to return to your host shell."); - logger.info(""); - - const exitCode = await new Promise((resolve) => { - const proc = spawn("openshell", ["sandbox", "connect", sandboxName], { - stdio: "inherit", - }); - proc.on("close", resolve); - proc.on("error", (err) => { - if (err.message.includes("ENOENT")) { - logger.error("openshell CLI not found. Is OpenShell installed?"); - } else { - logger.error(`Connection failed: ${err.message}`); - } - resolve(1); - }); - }); - - if (exitCode !== 0 && exitCode !== null) { - logger.error(`Sandbox '${sandboxName}' exited with code ${String(exitCode)}.`); - logger.info("Run 'openclaw nemoclaw status' to check available sandboxes."); - } -} diff --git a/nemoclaw/src/commands/eject.ts b/nemoclaw/src/commands/eject.ts deleted file mode 100644 index cfd34281ba4..00000000000 --- a/nemoclaw/src/commands/eject.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { execBlueprint } from "../blueprint/exec.js"; -import { loadState, clearState } from "../blueprint/state.js"; -import { restoreSnapshotToHost } from "./migration-state.js"; - -const HOME = process.env.HOME ?? "/tmp"; - -export interface EjectOptions { - runId?: string; - confirm: boolean; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -export async function cliEject(opts: EjectOptions): Promise { - const { confirm, runId, logger } = opts; - const state = loadState(); - - if (!state.lastAction) { - logger.error("No NemoClaw deployment found. Nothing to eject from."); - return; - } - - if (!state.migrationSnapshot && !state.hostBackupPath) { - logger.error("No migration snapshot found. Cannot restore host installation."); - logger.info("If you used --skip-backup during migrate, manual restoration is required."); - return; - } - - const snapshotPath = state.migrationSnapshot ?? state.hostBackupPath; - if (!snapshotPath) { - logger.error("No snapshot or backup path found in state. Cannot restore."); - return; - } - const snapshotOpenClawDir = join(snapshotPath, "openclaw"); - - if (!existsSync(snapshotOpenClawDir)) { - logger.error(`Snapshot directory not found: ${snapshotOpenClawDir}`); - return; - } - - if (!confirm) { - logger.info("Eject will:"); - logger.info(" 1. Stop the OpenShell sandbox"); - logger.info(" 2. Rollback blueprint state"); - logger.info(` 3. Restore ~/.openclaw from snapshot: ${snapshotPath}`); - logger.info(" 4. Clear NemoClaw state"); - logger.info(""); - logger.info("Run with --confirm to proceed, or cancel now."); - return; - } - - // Step 1: Rollback blueprint - if (state.lastRunId && state.blueprintVersion) { - const blueprintPath = join(HOME, ".nemoclaw", "blueprints", state.blueprintVersion); - - if (existsSync(blueprintPath)) { - const rollbackResult = await execBlueprint( - { - blueprintPath, - action: "rollback", - profile: "default", - runId: runId ?? state.lastRunId, - jsonOutput: true, - }, - logger, - ); - - if (!rollbackResult.success) { - logger.warn(`Blueprint rollback returned errors: ${rollbackResult.output}`); - logger.info("Continuing with host restoration..."); - } - } - } - - // Step 2: Restore host state using the original snapshot manifest paths. - const restored = restoreSnapshotToHost(snapshotPath, logger); - if (!restored) { - logger.info(`Manual restore available at: ${snapshotOpenClawDir}`); - return; - } - - // Step 3: Clear NemoClaw state - clearState(); - - logger.info(""); - logger.info("Eject complete. Host OpenClaw installation has been restored."); - logger.info("You can now run 'openclaw' directly on your host."); -} diff --git a/nemoclaw/src/commands/launch.ts b/nemoclaw/src/commands/launch.ts deleted file mode 100644 index ee08e96bf8b..00000000000 --- a/nemoclaw/src/commands/launch.ts +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execSync } from "node:child_process"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { resolveBlueprint } from "../blueprint/resolve.js"; -import { verifyBlueprintDigest, checkCompatibility } from "../blueprint/verify.js"; -import { execBlueprint } from "../blueprint/exec.js"; -import { loadState, saveState } from "../blueprint/state.js"; -import { detectHostOpenClaw } from "./migrate.js"; - -export interface LaunchOptions { - force: boolean; - profile: string; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -export async function cliLaunch(opts: LaunchOptions): Promise { - const { force, profile, logger, pluginConfig } = opts; - - logger.info("NemoClaw launch: setting up OpenClaw inside OpenShell"); - - // Check if there's an existing host OpenClaw installation - const hostState = detectHostOpenClaw(); - - if (!hostState.exists && !force) { - logger.info(""); - logger.info("No existing OpenClaw installation detected on this host."); - logger.info(""); - logger.info("For net-new users, the recommended path is OpenShell-native setup:"); - logger.info(""); - logger.info(" openshell sandbox create --from openclaw --name openclaw"); - logger.info(" openshell sandbox connect openclaw"); - logger.info(""); - logger.info( - "This avoids installing OpenClaw on the host only to redeploy it inside OpenShell.", - ); - logger.info(""); - logger.info("To proceed with NemoClaw-driven bootstrap anyway, use --force."); - return; - } - - if (hostState.exists && !force) { - logger.info( - "Existing OpenClaw installation detected. Consider using 'openclaw nemoclaw migrate' instead.", - ); - logger.info( - "Use --force to proceed with a fresh launch (existing config will not be migrated).", - ); - return; - } - - // Resolve and verify blueprint - logger.info("Resolving blueprint..."); - const blueprint = await resolveBlueprint(pluginConfig); - - logger.info("Verifying blueprint integrity..."); - const verification = verifyBlueprintDigest(blueprint.localPath, blueprint.manifest); - if (!verification.valid) { - logger.error(`Blueprint verification failed: ${verification.errors.join(", ")}`); - return; - } - - // Check version compatibility - const openshellVersion = getOpenshellVersion(); - const openclawVersion = getOpenclawVersion(); - const compat = checkCompatibility(blueprint.manifest, openshellVersion, openclawVersion); - if (compat.length > 0) { - logger.error(`Compatibility check failed:\n ${compat.join("\n ")}`); - return; - } - - // Plan - logger.info("Planning deployment..."); - const planResult = await execBlueprint( - { - blueprintPath: blueprint.localPath, - action: "plan", - profile, - jsonOutput: true, - }, - logger, - ); - - if (!planResult.success) { - logger.error(`Blueprint plan failed: ${planResult.output}`); - return; - } - - // Apply - logger.info("Deploying OpenClaw sandbox..."); - const applyResult = await execBlueprint( - { - blueprintPath: blueprint.localPath, - action: "apply", - profile, - planPath: planResult.runId, - jsonOutput: true, - }, - logger, - ); - - if (!applyResult.success) { - logger.error(`Blueprint apply failed: ${applyResult.output}`); - return; - } - - // Save state - saveState({ - ...loadState(), - lastRunId: applyResult.runId, - lastAction: "launch", - blueprintVersion: blueprint.version, - sandboxName: pluginConfig.sandboxName, - }); - - logger.info(""); - logger.info("OpenClaw is now running inside OpenShell."); - logger.info(`Sandbox: ${pluginConfig.sandboxName}`); - logger.info(""); - logger.info("Next steps:"); - logger.info(" openclaw nemoclaw connect # Enter the sandbox"); - logger.info(" openclaw nemoclaw status # Check health"); - logger.info(" openshell term # Monitor network egress"); -} - -function getOpenshellVersion(): string { - try { - return execSync("openshell --version", { encoding: "utf-8" }).trim(); - } catch { - return "0.0.0"; - } -} - -function getOpenclawVersion(): string { - try { - return execSync("openclaw --version", { encoding: "utf-8" }).trim(); - } catch { - return "0.0.0"; - } -} diff --git a/nemoclaw/src/commands/logs.ts b/nemoclaw/src/commands/logs.ts deleted file mode 100644 index 701daa232f9..00000000000 --- a/nemoclaw/src/commands/logs.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * `openclaw nemoclaw logs` — stream or tail blueprint execution and sandbox logs. - */ - -import { exec, spawn } from "node:child_process"; -import { promisify } from "node:util"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { loadState } from "../blueprint/state.js"; - -const execAsync = promisify(exec); - -export interface LogsOptions { - follow: boolean; - lines: number; - runId?: string; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -export async function cliLogs(opts: LogsOptions): Promise { - const { follow, lines, runId, logger, pluginConfig } = opts; - const state = loadState(); - const sandboxName = state.sandboxName ?? pluginConfig.sandboxName; - - const targetRunId = runId ?? state.lastRunId; - - if (targetRunId) { - logger.info(`Blueprint run: ${targetRunId}`); - logger.info(`Action: ${state.lastAction ?? "unknown"}`); - logger.info(""); - } - - // Stream sandbox logs via openshell - const sandboxRunning = await isSandboxRunning(sandboxName); - if (!sandboxRunning) { - logger.info(`Sandbox '${sandboxName}' is not running. No live logs available.`); - return; - } - - logger.info(`Streaming logs from sandbox '${sandboxName}'...`); - logger.info(""); - - const args = ["sandbox", "connect", sandboxName, "--", "tail"]; - if (follow) args.push("-f"); - args.push("-n", String(lines)); - args.push("/tmp/nemoclaw.log", "/tmp/openclaw.log"); - - const proc = spawn("openshell", args, { stdio: ["ignore", "inherit", "inherit"] }); - - await new Promise((resolve) => { - proc.on("close", () => resolve()); - proc.on("error", (err) => { - logger.error(`Failed to stream logs: ${err.message}`); - resolve(); - }); - }); -} - -async function isSandboxRunning(sandboxName: string): Promise { - try { - const { stdout } = await execAsync(`openshell sandbox get ${sandboxName} --json`, { - timeout: 5000, - }); - const parsed = JSON.parse(stdout) as { state?: string }; - return parsed.state === "running"; - } catch { - return false; - } -} diff --git a/nemoclaw/src/commands/migrate.ts b/nemoclaw/src/commands/migrate.ts deleted file mode 100644 index 55869327b6c..00000000000 --- a/nemoclaw/src/commands/migrate.ts +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execFileSync } from "node:child_process"; -import { join, posix as pathPosix } from "node:path"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { resolveBlueprint } from "../blueprint/resolve.js"; -import { verifyBlueprintDigest } from "../blueprint/verify.js"; -import { execBlueprint } from "../blueprint/exec.js"; -import { loadState, saveState } from "../blueprint/state.js"; -import { - cleanupSnapshotBundle, - createArchiveFromDirectory, - createSnapshotBundle, - detectHostOpenClaw, - loadSnapshotManifest, - type SnapshotBundle, -} from "./migration-state.js"; - -export { detectHostOpenClaw, type HostOpenClawState } from "./migration-state.js"; - -const SANDBOX_ARCHIVE_DIR = "/sandbox/.nemoclaw/migration/archives"; - -export interface MigrateOptions { - dryRun: boolean; - profile: string; - skipBackup: boolean; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -export async function cliMigrate(opts: MigrateOptions): Promise { - const { dryRun, profile, skipBackup, logger, pluginConfig } = opts; - - logger.info("NemoClaw migrate: moving host OpenClaw into OpenShell sandbox"); - - logger.info("Detecting host OpenClaw installation..."); - const hostState = detectHostOpenClaw(); - - if (!hostState.exists || !hostState.stateDir) { - logger.error("No OpenClaw installation found for the current host environment."); - logger.info("Use 'openclaw nemoclaw launch' for a fresh install."); - return; - } - - logger.info(`Resolved state dir: ${hostState.stateDir}`); - if (hostState.configPath) logger.info(` Config: ${hostState.configPath}`); - if (hostState.workspaceDir) logger.info(` Workspace: ${hostState.workspaceDir}`); - if (hostState.extensionsDir) logger.info(` Extensions: ${hostState.extensionsDir}`); - if (hostState.skillsDir) logger.info(` Skills: ${hostState.skillsDir}`); - if (hostState.hooksDir) logger.info(` Hooks: ${hostState.hooksDir}`); - for (const root of hostState.externalRoots) { - logger.info(` External ${root.kind}: ${root.sourcePath} -> ${root.sandboxPath}`); - } - for (const warning of hostState.warnings) { - logger.warn(warning); - } - if (hostState.errors.length > 0) { - for (const error of hostState.errors) { - logger.error(error); - } - logger.error("Refusing to migrate until all external OpenClaw roots can be resolved."); - return; - } - - if (dryRun) { - logger.info(""); - logger.info("[Dry run] Would perform the following:"); - logger.info(` 1. Snapshot state dir: ${hostState.stateDir}`); - if (hostState.configPath && hostState.hasExternalConfig) { - logger.info(` 2. Capture external config file: ${hostState.configPath}`); - } - if (hostState.externalRoots.length > 0) { - logger.info(" 3. Capture external OpenClaw roots and rewrite config paths for the sandbox:"); - for (const root of hostState.externalRoots) { - logger.info(` - ${root.sourcePath} -> ${root.sandboxPath}`); - } - logger.info(" 4. Package state and external roots as tar archives to preserve symlinks"); - logger.info(" 5. Copy archives into the OpenShell sandbox and verify the migrated paths"); - } else { - logger.info(" 3. Package state dir as a tar archive to preserve symlinks"); - logger.info(" 4. Copy the state archive into the OpenShell sandbox and verify the config"); - } - logger.info(" 6. Leave the host installation untouched and keep a rollback snapshot"); - return; - } - - logger.info("Resolving blueprint..."); - const blueprint = await resolveBlueprint(pluginConfig); - - logger.info("Verifying blueprint..."); - const verification = verifyBlueprintDigest(blueprint.localPath, blueprint.manifest); - if (!verification.valid) { - logger.error(`Blueprint verification failed: ${verification.errors.join(", ")}`); - return; - } - - logger.info("Planning migration..."); - const planResult = await execBlueprint( - { - blueprintPath: blueprint.localPath, - action: "plan", - profile, - jsonOutput: true, - }, - logger, - ); - - if (!planResult.success) { - logger.error(`Migration plan failed: ${planResult.output}`); - return; - } - - logger.info("Provisioning OpenShell sandbox..."); - const applyResult = await execBlueprint( - { - blueprintPath: blueprint.localPath, - action: "apply", - profile, - planPath: planResult.runId, - jsonOutput: true, - }, - logger, - ); - - if (!applyResult.success) { - logger.error(`Migration apply failed: ${applyResult.output}`); - return; - } - - logger.info("Creating migration snapshot..."); - const bundle = createSnapshotBundle(hostState, logger, { persist: !skipBackup }); - if (!bundle) { - return; - } - logger.info(`Snapshot saved to ${bundle.snapshotDir}`); - - try { - logger.info("Packaging OpenClaw state for sandbox import..."); - await buildMigrationArchives(bundle); - - logger.info("Syncing migration bundle into sandbox..."); - syncSnapshotBundleIntoSandbox(bundle, pluginConfig.sandboxName); - - logger.info("Verifying sandbox migration..."); - verifySandboxMigration(bundle, pluginConfig.sandboxName); - - saveState({ - ...loadState(), - lastRunId: applyResult.runId, - lastAction: "migrate", - blueprintVersion: blueprint.version, - sandboxName: pluginConfig.sandboxName, - migrationSnapshot: skipBackup ? null : bundle.snapshotDir, - hostBackupPath: skipBackup ? null : bundle.snapshotDir, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - logger.error(`Migration sync failed: ${msg}`); - logger.info("Your host installation is unchanged. Resolve the error and rerun migrate."); - return; - } finally { - cleanupSnapshotBundle(bundle); - } - - logger.info(""); - logger.info("Migration complete. OpenClaw is now running inside OpenShell."); - logger.info(`Sandbox: ${pluginConfig.sandboxName}`); - logger.info(""); - logger.info("Next steps:"); - logger.info(" openclaw nemoclaw connect # Enter the sandbox"); - logger.info(" openclaw nemoclaw status # Verify everything is healthy"); - logger.info(" openshell term # Monitor sandbox activity"); - logger.info(""); - logger.info("To rollback to your host installation:"); - if (skipBackup) { - logger.info(" Re-run migrate without --skip-backup to keep a rollback snapshot."); - } else { - logger.info(" openclaw nemoclaw eject"); - } -} - -async function buildMigrationArchives(bundle: SnapshotBundle): Promise { - await createArchiveFromDirectory(bundle.preparedStateDir, stateArchivePath(bundle)); - for (const root of bundle.manifest.externalRoots) { - await createArchiveFromDirectory(join(bundle.snapshotDir, root.snapshotRelativePath), rootArchivePath(bundle, root.id)); - } -} - -function syncSnapshotBundleIntoSandbox(bundle: SnapshotBundle, sandboxName: string): void { - execSandboxCommand(sandboxName, ["sh", "-lc", `mkdir -p ${shellQuote(SANDBOX_ARCHIVE_DIR)}`]); - - syncArchive(sandboxName, "state.tar", stateArchivePath(bundle), "/sandbox/.openclaw"); - for (const root of bundle.manifest.externalRoots) { - syncArchive( - sandboxName, - `${root.id}.tar`, - rootArchivePath(bundle, root.id), - root.sandboxPath, - ); - } -} - -function syncArchive(sandboxName: string, archiveName: string, archivePath: string, destinationDir: string): void { - const sandboxArchivePath = pathPosix.join(SANDBOX_ARCHIVE_DIR, archiveName); - execFileSync("openshell", ["sandbox", "cp", archivePath, `${sandboxName}:${sandboxArchivePath}`], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - }); - - const extractCommand = [ - "sh", - "-lc", - `mkdir -p ${shellQuote(destinationDir)} && tar -xf ${shellQuote(sandboxArchivePath)} -C ${shellQuote( - destinationDir, - )}`, - ]; - execSandboxCommand(sandboxName, extractCommand); -} - -function verifySandboxMigration(bundle: SnapshotBundle, sandboxName: string): void { - const manifest = loadSnapshotManifest(bundle.snapshotDir); - const verification = { - stateDir: "/sandbox/.openclaw", - configPath: "/sandbox/.openclaw/openclaw.json", - roots: manifest.externalRoots.map((root) => ({ - id: root.id, - sandboxPath: root.sandboxPath, - bindings: root.bindings.map((binding) => ({ - path: binding.configPath, - value: root.sandboxPath, - })), - symlinkPaths: root.symlinkPaths, - })), - }; - - const script = ` -const fs = require("node:fs"); -const verification = ${JSON.stringify(verification)}; -if (!fs.existsSync(verification.stateDir)) { - throw new Error(\`Missing migrated state dir: \${verification.stateDir}\`); -} -const config = JSON.parse(fs.readFileSync(verification.configPath, "utf-8")); -const get = (obj, path) => path.match(/[^.[\\]]+/g).reduce((value, token) => value?.[Number.isInteger(Number(token)) ? Number(token) : token], obj); -for (const root of verification.roots) { - if (!fs.existsSync(root.sandboxPath)) { - throw new Error(\`Missing migrated root: \${root.sandboxPath}\`); - } - for (const binding of root.bindings) { - const actual = get(config, binding.path); - if (actual !== binding.value) { - throw new Error(\`Config path \${binding.path} expected \${binding.value} but found \${actual}\`); - } - } - for (const relativePath of root.symlinkPaths) { - const targetPath = relativePath === "." ? root.sandboxPath : require("node:path").join(root.sandboxPath, relativePath); - const stat = fs.lstatSync(targetPath); - if (!stat.isSymbolicLink()) { - throw new Error(\`Expected symlink after migration: \${targetPath}\`); - } - } -} -`; - - execSandboxCommand(sandboxName, ["node", "-e", script]); -} - -function execSandboxCommand(sandboxName: string, args: string[]): void { - try { - execFileSync("openshell", ["sandbox", "connect", sandboxName, "--", ...args], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (err: unknown) { - const stderr = - err && - typeof err === "object" && - "stderr" in err && - typeof (err as { stderr?: unknown }).stderr === "string" - ? (err as { stderr: string }).stderr.trim() - : ""; - throw new Error(stderr || String(err)); - } -} - -function stateArchivePath(bundle: SnapshotBundle): string { - return join(bundle.archivesDir, "state.tar"); -} - -function rootArchivePath(bundle: SnapshotBundle, rootId: string): string { - return join(bundle.archivesDir, `${rootId}.tar`); -} - -function shellQuote(input: string): string { - return `'${input.replace(/'/g, `'\\''`)}'`; -} diff --git a/nemoclaw/src/commands/onboard.ts b/nemoclaw/src/commands/onboard.ts deleted file mode 100644 index 72fb9fcdd4c..00000000000 --- a/nemoclaw/src/commands/onboard.ts +++ /dev/null @@ -1,523 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execFileSync, execSync } from "node:child_process"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { - describeOnboardEndpoint, - describeOnboardProvider, - loadOnboardConfig, - saveOnboardConfig, - type EndpointType, - type NemoClawOnboardConfig, -} from "../onboard/config.js"; -import { promptInput, promptConfirm, promptSelect } from "../onboard/prompt.js"; -import { validateApiKey, maskApiKey } from "../onboard/validate.js"; - -export interface OnboardOptions { - apiKey?: string; - endpoint?: string; - ncpPartner?: string; - endpointUrl?: string; - model?: string; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -const ENDPOINT_TYPES: EndpointType[] = ["build", "ncp", "nim-local", "vllm", "ollama", "custom"]; -const SUPPORTED_ENDPOINT_TYPES: EndpointType[] = ["build", "ncp", "ollama"]; - -function isExperimentalEnabled(): boolean { - return process.env.NEMOCLAW_EXPERIMENTAL === "1"; -} - -const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; -const HOST_GATEWAY_URL = "http://host.openshell.internal"; - -const DEFAULT_MODELS = [ - { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, - { id: "moonshotai/kimi-k2.5", label: "Kimi K2.5" }, - { id: "z-ai/glm5", label: "GLM-5" }, - { id: "minimaxai/minimax-m2.5", label: "MiniMax M2.5" }, - { id: "qwen/qwen3.5-397b-a17b", label: "Qwen3.5 397B A17B" }, - { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, -]; -const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b"; - -function resolveProfile(endpointType: EndpointType): string { - switch (endpointType) { - case "build": - return "default"; - case "ncp": - case "custom": - return "ncp"; - case "nim-local": - return "nim-local"; - case "vllm": - return "vllm"; - case "ollama": - return "ollama"; - } -} - -function resolveProviderName(endpointType: EndpointType): string { - switch (endpointType) { - case "build": - return "nvidia-nim"; - case "ncp": - case "custom": - return "nvidia-ncp"; - case "nim-local": - return "nim-local"; - case "vllm": - return "vllm-local"; - case "ollama": - return "ollama-local"; - } -} - -function resolveCredentialEnv(endpointType: EndpointType): string { - switch (endpointType) { - case "build": - case "ncp": - case "custom": - return "NVIDIA_API_KEY"; - case "nim-local": - return "NIM_API_KEY"; - case "vllm": - case "ollama": - return "OPENAI_API_KEY"; - } -} - -function isNonInteractive(opts: OnboardOptions): boolean { - if (!opts.endpoint || !opts.model) return false; - const ep = opts.endpoint as EndpointType; - if (endpointRequiresApiKey(ep) && !opts.apiKey) return false; - if ((ep === "ncp" || ep === "nim-local" || ep === "custom") && !opts.endpointUrl) return false; - if (ep === "ncp" && !opts.ncpPartner) return false; - return true; -} - -function endpointRequiresApiKey(endpointType: EndpointType): boolean { - return ( - endpointType === "build" || - endpointType === "ncp" || - endpointType === "nim-local" || - endpointType === "custom" - ); -} - -function defaultCredentialForEndpoint(endpointType: EndpointType): string { - switch (endpointType) { - case "vllm": - return "dummy"; - case "ollama": - return "ollama"; - default: - return ""; - } -} - -function detectOllama(): { installed: boolean; running: boolean } { - const installed = testCommand("command -v ollama >/dev/null 2>&1"); - const running = testCommand("curl -sf http://localhost:11434/api/tags >/dev/null 2>&1"); - return { installed, running }; -} - -function parseOllamaList(output: string): string[] { - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .filter((line) => !/^NAME\s+/i.test(line)) - .map((line) => line.split(/\s{2,}/)[0]) - .filter(Boolean); -} - -function getOllamaModelOptions(): string[] { - try { - const output = execSync("ollama list", { encoding: "utf-8", shell: "/bin/bash" }); - const parsed = parseOllamaList(output); - if (parsed.length > 0) { - return parsed; - } - } catch {} - return [DEFAULT_OLLAMA_MODEL]; -} - -function getDefaultOllamaModel(): string { - const models = getOllamaModelOptions(); - return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0]; -} - -function testCommand(command: string): boolean { - try { - execSync(command, { encoding: "utf-8", stdio: "ignore", shell: "/bin/bash" }); - return true; - } catch { - return false; - } -} - -function showConfig(config: NemoClawOnboardConfig, logger: PluginLogger): void { - logger.info(` Endpoint: ${describeOnboardEndpoint(config)}`); - logger.info(` Provider: ${describeOnboardProvider(config)}`); - if (config.ncpPartner) { - logger.info(` NCP Partner: ${config.ncpPartner}`); - } - logger.info(` Model: ${config.model}`); - logger.info(` Credential: $${config.credentialEnv}`); - logger.info(` Profile: ${config.profile}`); - logger.info(` Onboarded: ${config.onboardedAt}`); -} - -async function promptEndpoint( - ollama: { installed: boolean; running: boolean }, -): Promise { - const options = [ - { - label: "NVIDIA Build (build.nvidia.com)", - value: "build", - hint: "recommended — zero infra, free credits", - }, - { - label: "NVIDIA Cloud Partner (NCP)", - value: "ncp", - hint: "dedicated capacity, SLA-backed", - }, - ]; - - options.push({ - label: "Local Ollama", - value: "ollama", - hint: ollama.running - ? "detected on localhost:11434" - : ollama.installed - ? "installed locally" - : "localhost:11434", - }); - - if (isExperimentalEnabled()) { - options.push( - { - label: "Self-hosted NIM [experimental]", - value: "nim-local", - hint: "experimental — your own NIM container deployment", - }, - { - label: "Local vLLM [experimental]", - value: "vllm", - hint: "experimental — local development", - }, - ); - } - - return (await promptSelect("Select your inference endpoint:", options)) as EndpointType; -} - -function execOpenShell(args: string[]): string { - return execFileSync("openshell", args, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); -} - -export async function cliOnboard(opts: OnboardOptions): Promise { - const { logger } = opts; - const nonInteractive = isNonInteractive(opts); - - logger.info("NemoClaw Onboarding"); - logger.info("-------------------"); - - // Step 0: Check existing config - const existing = loadOnboardConfig(); - if (existing) { - logger.info(""); - logger.info("Existing configuration found:"); - showConfig(existing, logger); - logger.info(""); - - if (!nonInteractive) { - const reconfigure = await promptConfirm("Reconfigure?", false); - if (!reconfigure) { - logger.info("Keeping existing configuration."); - return; - } - } - } - - // Step 1: Endpoint Selection - let endpointType: EndpointType; - if (opts.endpoint) { - if (!ENDPOINT_TYPES.includes(opts.endpoint as EndpointType)) { - logger.error( - `Invalid endpoint type: ${opts.endpoint}. Must be one of: ${ENDPOINT_TYPES.join(", ")}`, - ); - return; - } - const ep = opts.endpoint as EndpointType; - if (!SUPPORTED_ENDPOINT_TYPES.includes(ep)) { - logger.warn( - `Note: '${ep}' is experimental and may not work reliably.`, - ); - } - endpointType = ep; - } else { - const ollama = detectOllama(); - if (ollama.running) { - logger.info("Detected local inference option: Ollama."); - logger.info("Select it explicitly if you want to use it."); - } - endpointType = await promptEndpoint(ollama); - } - - // Step 2: Endpoint URL resolution - let endpointUrl: string; - let ncpPartner: string | null = null; - - switch (endpointType) { - case "build": - endpointUrl = BUILD_ENDPOINT_URL; - break; - case "ncp": - ncpPartner = opts.ncpPartner ?? (await promptInput("NCP partner name")); - endpointUrl = - opts.endpointUrl ?? - (await promptInput("NCP endpoint URL (e.g., https://partner.api.nvidia.com/v1)")); - break; - case "nim-local": - endpointUrl = - opts.endpointUrl ?? - (await promptInput("NIM endpoint URL", "http://nim-service.local:8000/v1")); - break; - case "vllm": - endpointUrl = `${HOST_GATEWAY_URL}:8000/v1`; - break; - case "ollama": - endpointUrl = opts.endpointUrl ?? `${HOST_GATEWAY_URL}:11434/v1`; - break; - case "custom": - endpointUrl = opts.endpointUrl ?? (await promptInput("Custom endpoint URL")); - break; - } - - if (!endpointUrl) { - logger.error("No endpoint URL provided. Aborting."); - return; - } - - const credentialEnv = resolveCredentialEnv(endpointType); - const requiresApiKey = endpointRequiresApiKey(endpointType); - - // Step 3: Credential - let apiKey = defaultCredentialForEndpoint(endpointType); - if (requiresApiKey) { - if (opts.apiKey) { - apiKey = opts.apiKey; - } else { - const envKey = process.env.NVIDIA_API_KEY; - if (envKey) { - logger.info(`Detected NVIDIA_API_KEY in environment (${maskApiKey(envKey)})`); - const useEnv = nonInteractive ? true : await promptConfirm("Use this key?"); - apiKey = useEnv ? envKey : await promptInput("Enter your NVIDIA API key"); - } else { - logger.info("Get an API key from: https://build.nvidia.com/settings/api-keys"); - apiKey = await promptInput("Enter your NVIDIA API key"); - } - } - } else { - logger.info( - `No API key required for ${endpointType}. Using local credential value '${apiKey}'.`, - ); - } - - if (!apiKey) { - logger.error("No API key provided. Aborting."); - return; - } - - // Step 4: Validate API Key - // For local endpoints (vllm, ollama, nim-local), validation is best-effort since the - // service may not be running yet during onboarding. - const isLocalEndpoint = - endpointType === "vllm" || endpointType === "ollama" || endpointType === "nim-local"; - logger.info(""); - logger.info(`Validating ${requiresApiKey ? "credential" : "endpoint"} against ${endpointUrl}...`); - const validation = await validateApiKey(apiKey, endpointUrl); - - if (!validation.valid) { - if (isLocalEndpoint) { - logger.warn( - `Could not reach ${endpointUrl} (${validation.error ?? "unknown error"}). Continuing anyway — the service may not be running yet.`, - ); - } else { - logger.error(`API key validation failed: ${validation.error ?? "unknown error"}`); - logger.info("Check your key at https://build.nvidia.com/settings/api-keys"); - return; - } - } else { - logger.info( - `${requiresApiKey ? "Credential" : "Endpoint"} valid. ${String(validation.models.length)} model(s) available.`, - ); - } - - // Step 5: Model Selection - let model: string; - if (opts.model) { - model = opts.model; - } else { - const discoveredModelOptions = - endpointType === "ollama" - ? getOllamaModelOptions().map((id) => ({ label: id, value: id })) - : validation.models.map((id) => ({ label: id, value: id })); - const curatedCloudOptions = - endpointType === "build" || endpointType === "ncp" - ? DEFAULT_MODELS.filter((option) => validation.models.includes(option.id)).map((option) => ({ - label: `${option.label} (${option.id})`, - value: option.id, - })) - : []; - const defaultIndex = - endpointType === "ollama" - ? Math.max( - 0, - discoveredModelOptions.findIndex((option) => option.value === getDefaultOllamaModel()), - ) - : 0; - const modelOptions = - curatedCloudOptions.length > 0 - ? curatedCloudOptions - : discoveredModelOptions.length > 0 - ? discoveredModelOptions - : DEFAULT_MODELS.map((m) => ({ label: `${m.label} (${m.id})`, value: m.id })); - - model = await promptSelect("Select your primary model:", modelOptions, defaultIndex); - } - - // Step 6: Resolve profile - const profile = resolveProfile(endpointType); - const providerName = resolveProviderName(endpointType); - const summaryConfig: NemoClawOnboardConfig = { - endpointType, - endpointUrl, - ncpPartner, - model, - profile, - credentialEnv, - provider: providerName, - providerLabel: undefined, - onboardedAt: "", - }; - summaryConfig.providerLabel = describeOnboardProvider(summaryConfig); - - // Step 7: Confirmation - logger.info(""); - logger.info("Configuration summary:"); - logger.info(` Endpoint: ${describeOnboardEndpoint(summaryConfig)}`); - logger.info(` Provider: ${summaryConfig.providerLabel}`); - if (ncpPartner) { - logger.info(` NCP Partner: ${ncpPartner}`); - } - logger.info(` Model: ${model}`); - logger.info( - ` API Key: ${requiresApiKey ? maskApiKey(apiKey) : "not required (local provider)"}`, - ); - logger.info(` Credential: $${credentialEnv}`); - logger.info(` Profile: ${profile}`); - logger.info(` Provider: ${providerName}`); - logger.info(""); - - if (!nonInteractive) { - const proceed = await promptConfirm("Apply this configuration?"); - if (!proceed) { - logger.info("Onboarding cancelled."); - return; - } - } - - // Step 8: Apply - logger.info(""); - logger.info("Applying configuration..."); - - // 7a: Create/update provider - try { - execOpenShell([ - "provider", - "create", - "--name", - providerName, - "--type", - "openai", - "--credential", - `${credentialEnv}=${apiKey}`, - "--config", - `OPENAI_BASE_URL=${endpointUrl}`, - ]); - logger.info(`Created provider: ${providerName}`); - } catch (err) { - const stderr = - err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; - if (stderr.includes("AlreadyExists") || stderr.includes("already exists")) { - try { - execOpenShell([ - "provider", - "update", - providerName, - "--credential", - `${credentialEnv}=${apiKey}`, - "--config", - `OPENAI_BASE_URL=${endpointUrl}`, - ]); - logger.info(`Updated provider: ${providerName}`); - } catch (updateErr) { - const updateStderr = - updateErr instanceof Error && "stderr" in updateErr - ? String((updateErr as { stderr: unknown }).stderr) - : ""; - logger.error(`Failed to update provider: ${updateStderr || String(updateErr)}`); - return; - } - } else { - logger.error(`Failed to create provider: ${stderr || String(err)}`); - return; - } - } - - // 7b: Set inference route - try { - execOpenShell(["inference", "set", "--provider", providerName, "--model", model]); - logger.info(`Inference route set: ${providerName} -> ${model}`); - } catch (err) { - const stderr = - err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; - logger.error(`Failed to set inference route: ${stderr || String(err)}`); - return; - } - - // 7c: Save config - saveOnboardConfig({ - endpointType, - endpointUrl, - ncpPartner, - model, - profile, - credentialEnv, - provider: providerName, - providerLabel: summaryConfig.providerLabel, - onboardedAt: new Date().toISOString(), - }); - - // Step 9: Success - logger.info(""); - logger.info("Onboarding complete!"); - logger.info(""); - logger.info(` Endpoint: ${describeOnboardEndpoint(summaryConfig)}`); - logger.info(` Provider: ${summaryConfig.providerLabel}`); - logger.info(` Model: ${model}`); - logger.info(` Credential: $${credentialEnv}`); - logger.info(""); - logger.info("Next steps:"); - logger.info(" openclaw nemoclaw launch # Bootstrap sandbox"); - logger.info(" openclaw nemoclaw status # Check configuration"); -} diff --git a/nemoclaw/src/commands/slash.ts b/nemoclaw/src/commands/slash.ts index bd9afa295d5..84fc120e24d 100644 --- a/nemoclaw/src/commands/slash.ts +++ b/nemoclaw/src/commands/slash.ts @@ -48,12 +48,11 @@ function slashHelp(): PluginCommandResult { " `eject` - Show rollback instructions", " `onboard` - Show onboarding status and instructions", "", - "For full management use the CLI:", - " `openclaw nemoclaw status`", - " `openclaw nemoclaw migrate`", - " `openclaw nemoclaw launch`", - " `openclaw nemoclaw connect`", - " `openclaw nemoclaw eject --confirm`", + "For full management use the NemoClaw CLI:", + " `nemoclaw status`", + " `nemoclaw connect`", + " `nemoclaw logs`", + " `nemoclaw destroy`", ].join("\n"), }; } @@ -63,7 +62,7 @@ function slashStatus(): PluginCommandResult { if (!state.lastAction) { return { - text: "**NemoClaw**: No operations performed yet. Run `openclaw nemoclaw launch` or `openclaw nemoclaw migrate` to get started.", + text: "**NemoClaw**: No operations performed yet. Run `nemoclaw onboard` to get started.", }; } @@ -99,7 +98,7 @@ function slashOnboard(): PluginCommandResult { `Profile: ${config.profile}`, `Onboarded: ${config.onboardedAt}`, "", - "To reconfigure, run: `openclaw nemoclaw onboard`", + "To reconfigure, run: `nemoclaw onboard`", ] .filter(Boolean) .join("\n"), @@ -112,12 +111,7 @@ function slashOnboard(): PluginCommandResult { "No configuration found. Run the onboard command to set up inference:", "", "```", - "openclaw nemoclaw onboard", - "```", - "", - "Or non-interactively:", - "```", - 'openclaw nemoclaw onboard --api-key "$NVIDIA_API_KEY" --endpoint build --model nvidia/nemotron-3-super-120b-a12b', + "nemoclaw onboard", "```", ].join("\n"), }; @@ -143,7 +137,7 @@ function slashEject(): PluginCommandResult { "To rollback to your host OpenClaw installation, run:", "", "```", - "openclaw nemoclaw eject --confirm", + "nemoclaw destroy", "```", "", `Snapshot: ${state.migrationSnapshot ?? state.hostBackupPath ?? "none"}`, diff --git a/nemoclaw/src/commands/status.test.ts b/nemoclaw/src/commands/status.test.ts deleted file mode 100644 index 78a55b47aa1..00000000000 --- a/nemoclaw/src/commands/status.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { NemoClawState } from "../blueprint/state.js"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -// Mock node:fs — controls isInsideSandbox() detection -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => false), -})); - -// Mock node:child_process — controls openshell command results -vi.mock("node:child_process", () => ({ - exec: vi.fn(), -})); - -// Mock state loader — controls plugin state -vi.mock("../blueprint/state.js", () => ({ - loadState: vi.fn(), -})); - -// Import after mocks are set up -const { existsSync } = await import("node:fs"); -const { exec } = await import("node:child_process"); -const { loadState } = await import("../blueprint/state.js"); -const { cliStatus } = await import("./status.js"); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function blankState(): NemoClawState { - return { - lastRunId: null, - lastAction: null, - blueprintVersion: null, - sandboxName: null, - migrationSnapshot: null, - hostBackupPath: null, - createdAt: null, - updatedAt: new Date().toISOString(), - }; -} - -function populatedState(): NemoClawState { - return { - lastRunId: "run-a1b2c3d4", - lastAction: "migrate", - blueprintVersion: "0.1.0", - sandboxName: "openclaw", - migrationSnapshot: "/root/.nemoclaw/snapshots/pre-migrate.tar.gz", - hostBackupPath: "/root/.nemoclaw/backups/host-backup", - createdAt: "2026-03-15T10:30:00.000Z", - updatedAt: "2026-03-15T10:32:45.000Z", - }; -} - -const defaultConfig: NemoClawConfig = { - blueprintVersion: "latest", - blueprintRegistry: "ghcr.io/nvidia/nemoclaw-blueprint", - sandboxName: "openclaw", - inferenceProvider: "nvidia", -}; - -/** Create a logger that captures all info() calls into an array. */ -function captureLogger(): { lines: string[]; logger: PluginLogger } { - const lines: string[] = []; - return { - lines, - logger: { - info: (msg: string) => lines.push(msg), - warn: (msg: string) => lines.push(`WARN: ${msg}`), - error: (msg: string) => lines.push(`ERROR: ${msg}`), - debug: (_msg: string) => {}, - }, - }; -} - -/** - * Make the exec mock resolve with the given stdout, or reject if error is set. - * Routes by command substring so sandbox and inference calls can differ. - */ -function mockExec(responses: Record): void { - vi.mocked(exec).mockImplementation((( - cmd: string, - _opts: unknown, - callback?: (err: Error | null, result: { stdout: string; stderr: string }) => void, - ) => { - // promisify(exec)(cmd, opts) calls exec(cmd, opts, callback) - for (const [substring, response] of Object.entries(responses)) { - if (cmd.includes(substring)) { - if (response instanceof Error) { - callback?.(response, { stdout: "", stderr: response.message }); - } else { - callback?.(null, { stdout: response, stderr: "" }); - } - return; - } - } - // Default: command not found - callback?.(new Error(`command not found: ${cmd}`), { stdout: "", stderr: "" }); - }) as typeof exec); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(existsSync).mockReturnValue(false); - vi.mocked(loadState).mockReturnValue(blankState()); - mockExec({}); -}); - -describe("cliStatus", () => { - // ========================================================================= - // Scenario 1: Host — no openshell, blank state - // ========================================================================= - describe("host — no openshell, blank state", () => { - it("shows 'not running' and 'Not configured' in text output", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Status: not running"); - expect(output).toContain("Not configured"); - expect(output).not.toContain("inside sandbox"); - expect(output).not.toContain("active (inside sandbox)"); - }); - - it("includes insideSandbox: false in JSON output", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(false); - expect(data.sandbox.insideSandbox).toBe(false); - expect(data.sandbox.running).toBe(false); - expect(data.inference.insideSandbox).toBe(false); - expect(data.inference.configured).toBe(false); - }); - - it("shows 'No operations have been performed yet'", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - expect(lines.join("\n")).toContain("No operations have been performed yet."); - }); - }); - - // ========================================================================= - // Scenario 2: Host — sandbox running, inference configured - // ========================================================================= - describe("host — sandbox running, inference configured", () => { - beforeEach(() => { - mockExec({ - "sandbox status": JSON.stringify({ state: "running", uptime: "2h 14m" }), - "inference get": JSON.stringify({ - provider: "nvidia", - model: "nemotron-3-super-120b", - endpoint: "https://integrate.api.nvidia.com", - }), - }); - }); - - it("shows running sandbox with uptime in text output", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Status: running"); - expect(output).toContain("Uptime: 2h 14m"); - expect(output).toContain("Name: openclaw"); - expect(output).not.toContain("inside sandbox"); - }); - - it("shows configured inference in text output", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Provider: nvidia"); - expect(output).toContain("Model: nemotron-3-super-120b"); - expect(output).toContain("Endpoint: https://integrate.api.nvidia.com"); - }); - - it("returns correct JSON structure", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(false); - expect(data.sandbox.running).toBe(true); - expect(data.sandbox.uptime).toBe("2h 14m"); - expect(data.sandbox.insideSandbox).toBe(false); - expect(data.inference.configured).toBe(true); - expect(data.inference.provider).toBe("nvidia"); - expect(data.inference.insideSandbox).toBe(false); - }); - }); - - // ========================================================================= - // Scenario 3: Host — sandbox running, no inference - // ========================================================================= - describe("host — sandbox running, no inference", () => { - beforeEach(() => { - mockExec({ - "sandbox status": JSON.stringify({ state: "running", uptime: "45m 12s" }), - "inference get": new Error("no inference configured"), - }); - }); - - it("shows running sandbox but 'Not configured' inference", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Status: running"); - expect(output).toContain("Not configured"); - expect(output).not.toContain("unable to query"); - }); - - it("JSON shows sandbox running, inference not configured, not inside sandbox", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.sandbox.running).toBe(true); - expect(data.inference.configured).toBe(false); - expect(data.inference.insideSandbox).toBe(false); - }); - }); - - // ========================================================================= - // Scenario 4: Inside sandbox — core bug fix - // ========================================================================= - describe("inside sandbox — core bug fix", () => { - beforeEach(() => { - vi.mocked(existsSync).mockImplementation((p: string | URL | Buffer) => { - const path = String(p); - return path === "/sandbox/.openclaw" || path === "/sandbox/.nemoclaw"; - }); - }); - - it("shows 'active (inside sandbox)' instead of 'not running'", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("active (inside sandbox)"); - expect(output).not.toContain("Status: not running"); - }); - - it("shows sandbox context banner", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Context: running inside an active OpenShell sandbox"); - expect(output).toContain("Host sandbox state is not inspectable from inside the sandbox."); - }); - - it("shows 'unable to query' instead of 'Not configured'", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("unable to query from inside sandbox"); - expect(output).not.toContain("Not configured"); - }); - - it("does not call openshell commands", async () => { - const { logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - expect(exec).not.toHaveBeenCalled(); - }); - - it("JSON output has insideSandbox: true everywhere", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(true); - expect(data.sandbox.insideSandbox).toBe(true); - expect(data.sandbox.running).toBe(false); - expect(data.inference.insideSandbox).toBe(true); - expect(data.inference.configured).toBe(false); - }); - }); - - // ========================================================================= - // Scenario 5: Inside sandbox with prior plugin state - // ========================================================================= - describe("inside sandbox — with prior plugin state", () => { - beforeEach(() => { - vi.mocked(existsSync).mockImplementation((p: string | URL | Buffer) => { - const path = String(p); - return path === "/sandbox/.openclaw" || path === "/sandbox/.nemoclaw"; - }); - vi.mocked(loadState).mockReturnValue(populatedState()); - }); - - it("shows plugin state from state file", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Last action: migrate"); - expect(output).toContain("Blueprint: 0.1.0"); - expect(output).toContain("Run ID: run-a1b2c3d4"); - }); - - it("shows rollback section when migrationSnapshot exists", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Rollback:"); - expect(output).toContain("Snapshot: /root/.nemoclaw/snapshots/pre-migrate.tar.gz"); - expect(output).toContain("openclaw nemoclaw eject"); - }); - - it("JSON includes full nemoclaw state alongside insideSandbox: true", async () => { - const { lines, logger } = captureLogger(); - - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(true); - expect(data.nemoclaw.lastAction).toBe("migrate"); - expect(data.nemoclaw.blueprintVersion).toBe("0.1.0"); - expect(data.nemoclaw.lastRunId).toBe("run-a1b2c3d4"); - expect(data.nemoclaw.migrationSnapshot).toBe( - "/root/.nemoclaw/snapshots/pre-migrate.tar.gz", - ); - }); - }); - - // ========================================================================= - // Edge cases - // ========================================================================= - describe("edge cases", () => { - it("uses state.sandboxName when available", async () => { - vi.mocked(loadState).mockReturnValue({ - ...blankState(), - sandboxName: "custom-sandbox", - }); - mockExec({ - "sandbox status": JSON.stringify({ state: "running", uptime: "1m" }), - "inference get": new Error("not configured"), - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Name: custom-sandbox"); - - // Verify the exec call used the custom sandbox name - expect(exec).toHaveBeenCalledWith( - expect.stringContaining("custom-sandbox"), - expect.anything(), - expect.anything(), - ); - }); - - it("defaults sandbox name to 'openclaw' when state has none", async () => { - mockExec({ - "sandbox status": new Error("not found"), - "inference get": new Error("not configured"), - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - // Verify exec was called with default name - expect(exec).toHaveBeenCalledWith( - expect.stringContaining("openclaw"), - expect.anything(), - expect.anything(), - ); - }); - - it("only detects sandbox via /sandbox/.openclaw", async () => { - vi.mocked(existsSync).mockImplementation((p: string | URL | Buffer) => { - return String(p) === "/sandbox/.openclaw"; - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(true); - }); - - it("only detects sandbox via /sandbox/.nemoclaw", async () => { - vi.mocked(existsSync).mockImplementation((p: string | URL | Buffer) => { - return String(p) === "/sandbox/.nemoclaw"; - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: true, logger, pluginConfig: defaultConfig }); - - const data = JSON.parse(lines.join("")); - expect(data.insideSandbox).toBe(true); - }); - - it("handles sandbox running but with missing uptime field", async () => { - mockExec({ - "sandbox status": JSON.stringify({ state: "running" }), - "inference get": new Error("not configured"), - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - const output = lines.join("\n"); - expect(output).toContain("Status: running"); - expect(output).toContain("Uptime: unknown"); - }); - - it("no rollback section when migrationSnapshot is null", async () => { - vi.mocked(loadState).mockReturnValue({ - ...populatedState(), - migrationSnapshot: null, - }); - - const { lines, logger } = captureLogger(); - await cliStatus({ json: false, logger, pluginConfig: defaultConfig }); - - expect(lines.join("\n")).not.toContain("Rollback:"); - }); - }); -}); diff --git a/nemoclaw/src/commands/status.ts b/nemoclaw/src/commands/status.ts deleted file mode 100644 index cf594dc8d5e..00000000000 --- a/nemoclaw/src/commands/status.ts +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { exec } from "node:child_process"; -import { existsSync } from "node:fs"; -import { promisify } from "node:util"; -import type { PluginLogger, NemoClawConfig } from "../index.js"; -import { loadState } from "../blueprint/state.js"; - -const execAsync = promisify(exec); - -/** - * Detect whether the plugin is running inside an OpenShell sandbox. - * Inside sandboxes the root filesystem is mounted at /sandbox and openshell - * host commands are not available, so querying `openshell sandbox status` - * would always fail — producing false-negative "not running" reports. - */ -function isInsideSandbox(): boolean { - return existsSync("/sandbox/.openclaw") || existsSync("/sandbox/.nemoclaw"); -} - -export interface StatusOptions { - json: boolean; - logger: PluginLogger; - pluginConfig: NemoClawConfig; -} - -export async function cliStatus(opts: StatusOptions): Promise { - const { json: jsonOutput, logger } = opts; - const state = loadState(); - const sandboxName = state.sandboxName ?? "openclaw"; - const insideSandbox = isInsideSandbox(); - - const [sandbox, inference] = await Promise.all([ - getSandboxStatus(sandboxName, insideSandbox), - getInferenceStatus(insideSandbox), - ]); - - const statusData = { - nemoclaw: { - lastAction: state.lastAction, - lastRunId: state.lastRunId, - blueprintVersion: state.blueprintVersion, - sandboxName: state.sandboxName, - migrationSnapshot: state.migrationSnapshot, - updatedAt: state.updatedAt, - }, - sandbox, - inference, - insideSandbox, - }; - - if (jsonOutput) { - logger.info(JSON.stringify(statusData, null, 2)); - return; - } - - logger.info("NemoClaw Status"); - logger.info("==============="); - logger.info(""); - - if (insideSandbox) { - logger.info("Context: running inside an active OpenShell sandbox"); - logger.info(" Host sandbox state is not inspectable from inside the sandbox."); - logger.info(" Run 'openshell sandbox status' on the host for full details."); - logger.info(""); - } - - logger.info("Plugin State:"); - if (state.lastAction) { - logger.info(` Last action: ${state.lastAction}`); - logger.info(` Blueprint: ${state.blueprintVersion ?? "unknown"}`); - logger.info(` Run ID: ${state.lastRunId ?? "none"}`); - logger.info(` Updated: ${state.updatedAt}`); - } else { - logger.info(" No operations have been performed yet."); - } - logger.info(""); - - logger.info("Sandbox:"); - if (sandbox.running) { - logger.info(` Name: ${sandbox.name}`); - logger.info(" Status: running"); - logger.info(` Uptime: ${sandbox.uptime ?? "unknown"}`); - } else if (sandbox.insideSandbox) { - logger.info(` Name: ${sandbox.name}`); - logger.info(" Status: active (inside sandbox)"); - logger.info(" Note: Cannot query host sandbox state from within the sandbox."); - } else { - logger.info(" Status: not running"); - } - logger.info(""); - - logger.info("Inference:"); - if (inference.configured) { - logger.info(` Provider: ${inference.provider ?? "unknown"}`); - logger.info(` Model: ${inference.model ?? "unknown"}`); - logger.info(` Endpoint: ${inference.endpoint ?? "unknown"}`); - } else if (inference.insideSandbox) { - logger.info(" Status: unable to query from inside sandbox"); - logger.info(" Note: Run 'openshell inference get' on the host to check."); - } else { - logger.info(" Not configured"); - } - - if (state.migrationSnapshot) { - logger.info(""); - logger.info("Rollback:"); - logger.info(` Snapshot: ${state.migrationSnapshot}`); - logger.info(" Run 'openclaw nemoclaw eject' to restore host installation."); - } -} - -interface SandboxStatus { - name: string; - running: boolean; - uptime: string | null; - insideSandbox: boolean; -} - -interface SandboxStatusResponse { - state?: string; - uptime?: string; -} - -async function getSandboxStatus(sandboxName: string, insideSandbox: boolean): Promise { - if (insideSandbox) { - return { name: sandboxName, running: false, uptime: null, insideSandbox: true }; - } - try { - const { stdout } = await execAsync(`openshell sandbox status ${sandboxName} --json`, { - timeout: 5000, - }); - const parsed = JSON.parse(stdout) as SandboxStatusResponse; - return { - name: sandboxName, - running: parsed.state === "running", - uptime: parsed.uptime ?? null, - insideSandbox: false, - }; - } catch { - return { name: sandboxName, running: false, uptime: null, insideSandbox: false }; - } -} - -interface InferenceStatus { - configured: boolean; - provider: string | null; - model: string | null; - endpoint: string | null; - insideSandbox: boolean; -} - -interface InferenceStatusResponse { - provider?: string; - model?: string; - endpoint?: string; -} - -async function getInferenceStatus(insideSandbox: boolean): Promise { - if (insideSandbox) { - return { configured: false, provider: null, model: null, endpoint: null, insideSandbox: true }; - } - try { - const { stdout } = await execAsync("openshell inference get --json", { - timeout: 5000, - }); - const parsed = JSON.parse(stdout) as InferenceStatusResponse; - return { - configured: true, - provider: parsed.provider ?? null, - model: parsed.model ?? null, - endpoint: parsed.endpoint ?? null, - insideSandbox: false, - }; - } catch { - return { configured: false, provider: null, model: null, endpoint: null, insideSandbox: false }; - } -} diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index f6defcd41b5..705b1305960 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -11,8 +11,6 @@ * time. */ -import type { Command } from "commander"; -import { registerCliCommands } from "./cli.js"; import { handleSlashCommand } from "./commands/slash.js"; import { describeOnboardEndpoint, @@ -66,17 +64,6 @@ export interface PluginCommandDefinition { handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise; } -/** Context passed to the CLI registrar callback. */ -export interface PluginCliContext { - program: Command; - config: OpenClawConfig; - workspaceDir?: string; - logger: PluginLogger; -} - -/** CLI registrar callback type. */ -export type PluginCliRegistrar = (ctx: PluginCliContext) => void | Promise; - /** Auth method for a provider plugin. */ export interface ProviderAuthMethod { type: string; @@ -129,7 +116,6 @@ export interface OpenClawPluginApi { pluginConfig?: Record; logger: PluginLogger; registerCommand: (command: PluginCommandDefinition) => void; - registerCli: (registrar: PluginCliRegistrar, opts?: { commands?: string[] }) => void; registerProvider: (provider: ProviderPlugin) => void; registerService: (service: PluginService) => void; resolvePath: (input: string) => string; @@ -248,15 +234,7 @@ export default function register(api: OpenClawPluginApi): void { handler: (ctx) => handleSlashCommand(ctx, api), }); - // 2. Register `openclaw nemoclaw` CLI subcommands (commander.js) - api.registerCli( - (cliCtx) => { - registerCliCommands(cliCtx, api); - }, - { commands: ["nemoclaw"] }, - ); - - // 3. Register nvidia-nim provider — use onboard config if available + // 2. Register nvidia-nim provider — use onboard config if available const onboardCfg = loadOnboardConfig(); const providerCredentialEnv = onboardCfg?.credentialEnv ?? "NVIDIA_API_KEY"; api.registerProvider(registeredProviderForConfig(onboardCfg, providerCredentialEnv)); @@ -272,7 +250,7 @@ export default function register(api: OpenClawPluginApi): void { api.logger.info(` │ Endpoint: ${bannerEndpoint.padEnd(40)}│`); api.logger.info(` │ Provider: ${bannerProvider.padEnd(40)}│`); api.logger.info(` │ Model: ${bannerModel.padEnd(40)}│`); - api.logger.info(" │ Commands: openclaw nemoclaw │"); + api.logger.info(" │ Slash: /nemoclaw │"); api.logger.info(" └─────────────────────────────────────────────────────┘"); api.logger.info(""); } diff --git a/nemoclaw/src/onboard/prompt.ts b/nemoclaw/src/onboard/prompt.ts deleted file mode 100644 index aa5bf49b64c..00000000000 --- a/nemoclaw/src/onboard/prompt.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createInterface } from "node:readline/promises"; -import { stdin, stdout } from "node:process"; - -export interface SelectOption { - label: string; - value: string; - hint?: string; -} - -export async function promptInput(question: string, defaultValue?: string): Promise { - const rl = createInterface({ input: stdin, output: stdout }); - const suffix = defaultValue ? ` [${defaultValue}]` : ""; - try { - const answer = await rl.question(`${question}${suffix}: `); - const trimmed = answer.trim(); - return trimmed || defaultValue || ""; - } finally { - rl.close(); - } -} - -export async function promptConfirm(question: string, defaultYes = true): Promise { - const rl = createInterface({ input: stdin, output: stdout }); - const hint = defaultYes ? "(Y/n)" : "(y/N)"; - try { - const answer = await rl.question(`${question} ${hint}: `); - const trimmed = answer.trim().toLowerCase(); - if (!trimmed) return defaultYes; - return trimmed === "y" || trimmed === "yes"; - } finally { - rl.close(); - } -} - -export async function promptSelect( - question: string, - options: SelectOption[], - defaultIndex = 0, -): Promise { - const rl = createInterface({ input: stdin, output: stdout }); - try { - console.log(`\n${question}\n`); - for (let i = 0; i < options.length; i++) { - const marker = i === defaultIndex ? "*" : " "; - const optHint = options[i].hint; - const hint = optHint ? ` ${optHint}` : ""; - console.log(` ${marker} ${String(i + 1)}. ${options[i].label}${hint}`); - } - console.log(""); - - for (;;) { - const answer = await rl.question( - `Select [1-${String(options.length)}] (default: ${String(defaultIndex + 1)}): `, - ); - const trimmed = answer.trim(); - - if (!trimmed) return options[defaultIndex].value; - - const num = parseInt(trimmed, 10); - if (!isNaN(num) && num >= 1 && num <= options.length) { - return options[num - 1].value; - } - - console.log(` Invalid choice. Enter a number between 1 and ${String(options.length)}.`); - } - } finally { - rl.close(); - } -} diff --git a/nemoclaw/src/onboard/validate.ts b/nemoclaw/src/onboard/validate.ts deleted file mode 100644 index c2b27de8b91..00000000000 --- a/nemoclaw/src/onboard/validate.ts +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export interface ValidationResult { - valid: boolean; - models: string[]; - error: string | null; -} - -export async function validateApiKey( - apiKey: string, - endpointUrl: string, -): Promise { - const url = `${endpointUrl.replace(/\/+$/, "")}/models`; - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, 10_000); - - try { - const response = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: controller.signal, - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - return { - valid: false, - models: [], - error: `HTTP ${String(response.status)}: ${body.slice(0, 200)}`, - }; - } - - const json = (await response.json()) as { data?: { id: string }[] }; - const models = (json.data ?? []).map((m) => m.id); - return { valid: true, models, error: null }; - } catch (err) { - const message = - err instanceof Error - ? err.name === "AbortError" - ? "Request timed out (10s)" - : err.message - : String(err); - return { valid: false, models: [], error: message }; - } finally { - clearTimeout(timeout); - } -} - -export function maskApiKey(apiKey: string): string { - if (apiKey.length <= 8) return "****"; - const last4 = apiKey.slice(-4); - if (apiKey.startsWith("nvapi-")) { - return `nvapi-****${last4}`; - } - return `****${last4}`; -} diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 39cd467821b..2ec70331bd7 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -212,16 +212,8 @@ pass "Migration inventory handles overrides, external roots, and symlink-safe ar info "9. Verify plugin TypeScript compilation" # ------------------------------------------------------- [ -f /opt/nemoclaw/dist/index.js ] && pass "index.js compiled" || fail "index.js missing" -[ -f /opt/nemoclaw/dist/commands/migrate.js ] && pass "migrate.js compiled" || fail "migrate.js missing" -[ -f /opt/nemoclaw/dist/commands/migration-state.js ] && pass "migration-state.js compiled" || fail "migration-state.js missing" -[ -f /opt/nemoclaw/dist/commands/launch.js ] && pass "launch.js compiled" || fail "launch.js missing" -[ -f /opt/nemoclaw/dist/commands/connect.js ] && pass "connect.js compiled" || fail "connect.js missing" -[ -f /opt/nemoclaw/dist/commands/eject.js ] && pass "eject.js compiled" || fail "eject.js missing" -[ -f /opt/nemoclaw/dist/commands/status.js ] && pass "status.js compiled" || fail "status.js missing" [ -f /opt/nemoclaw/dist/commands/slash.js ] && pass "slash.js compiled" || fail "slash.js missing" -[ -f /opt/nemoclaw/dist/blueprint/resolve.js ] && pass "resolve.js compiled" || fail "resolve.js missing" -[ -f /opt/nemoclaw/dist/blueprint/verify.js ] && pass "verify.js compiled" || fail "verify.js missing" -[ -f /opt/nemoclaw/dist/blueprint/exec.js ] && pass "exec.js compiled" || fail "exec.js missing" +[ -f /opt/nemoclaw/dist/commands/migration-state.js ] && pass "migration-state.js compiled" || fail "migration-state.js missing" [ -f /opt/nemoclaw/dist/blueprint/state.js ] && pass "state.js compiled" || fail "state.js missing" # ------------------------------------------------------- From 528a8bce6a6603110f29bdfd8414ce9ef033a422 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 20 Mar 2026 07:42:53 -0700 Subject: [PATCH 2/4] ci: pass vitest with no test files after removing status.test.ts --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 936b6ef91f1..bf94fe38275 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -42,7 +42,7 @@ jobs: - name: Run TypeScript unit tests working-directory: nemoclaw - run: npx vitest run + run: npx vitest run --passWithNoTests test-e2e-sandbox: runs-on: ubuntu-latest From 42e1a5cebd56b46893f2778c82a03afc9fa07da2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 20 Mar 2026 07:45:26 -0700 Subject: [PATCH 3/4] test: add plugin registration test verifying no CLI commands registered --- .github/workflows/pr.yaml | 2 +- nemoclaw/src/register.test.ts | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 nemoclaw/src/register.test.ts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index bf94fe38275..936b6ef91f1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -42,7 +42,7 @@ jobs: - name: Run TypeScript unit tests working-directory: nemoclaw - run: npx vitest run --passWithNoTests + run: npx vitest run test-e2e-sandbox: runs-on: ubuntu-latest diff --git a/nemoclaw/src/register.test.ts b/nemoclaw/src/register.test.ts new file mode 100644 index 00000000000..bcb905b1951 --- /dev/null +++ b/nemoclaw/src/register.test.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi } from "vitest"; +import register from "./index.js"; +import type { OpenClawPluginApi } from "./index.js"; + +function createMockApi(): OpenClawPluginApi { + return { + id: "nemoclaw", + name: "NemoClaw", + version: "0.1.0", + config: {}, + pluginConfig: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerCommand: vi.fn(), + registerProvider: vi.fn(), + registerService: vi.fn(), + resolvePath: vi.fn((p: string) => p), + on: vi.fn(), + }; +} + +describe("plugin registration", () => { + it("registers a slash command", () => { + const api = createMockApi(); + register(api); + expect(api.registerCommand).toHaveBeenCalledWith( + expect.objectContaining({ name: "nemoclaw" }), + ); + }); + + it("registers an inference provider", () => { + const api = createMockApi(); + register(api); + expect(api.registerProvider).toHaveBeenCalledWith( + expect.objectContaining({ id: "inference" }), + ); + }); + + it("does NOT register CLI commands", () => { + const api = createMockApi(); + // registerCli should not exist on the API interface after removal + expect("registerCli" in api).toBe(false); + }); +}); From 069d8d59aa3d133abfa4ad54edc664ab293b6f97 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 20 Mar 2026 07:53:42 -0700 Subject: [PATCH 4/4] docs: fix stale flag references caught by CodeRabbit review --- docs/monitoring/monitor-sandbox-activity.md | 17 ----------------- docs/reference/architecture.md | 2 +- docs/reference/troubleshooting.md | 1 - 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/docs/monitoring/monitor-sandbox-activity.md b/docs/monitoring/monitor-sandbox-activity.md index 3705e046c44..30270d1dcce 100644 --- a/docs/monitoring/monitor-sandbox-activity.md +++ b/docs/monitoring/monitor-sandbox-activity.md @@ -35,12 +35,6 @@ Run the status command to view the sandbox state, blueprint run information, and $ nemoclaw status ``` -For machine-readable output, add the `--json` flag: - -```console -$ nemoclaw status -``` - Key fields in the output include the following: - Sandbox state, which indicates whether the sandbox is running, stopped, or in an error state. @@ -63,17 +57,6 @@ To follow the log output in real time: $ nemoclaw logs -f ``` -To display a specific number of log lines: - -```console -$ nemoclaw logs -``` - -To view logs for a specific blueprint run instead of the most recent one: - -```console -$ nemoclaw logs -``` ## Monitor Network Activity in the TUI diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index f5dc543110c..bfd8894e18d 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -25,7 +25,7 @@ NemoClaw has two main components: a TypeScript plugin that integrates with the O ## NemoClaw Plugin The plugin is a thin TypeScript package that registers an inference provider and the `/nemoclaw` slash command. -It runs in-process with the OpenClaw gateway and handles user-facing CLI interactions. +It runs in-process with the OpenClaw gateway inside the sandbox. ```text nemoclaw/ diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 40daf06f6f9..16f345423eb 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -187,5 +187,4 @@ View the error output for the failed blueprint run: $ nemoclaw logs ``` -If the run ID is unknown, omit `--run-id` to view logs from the most recent run. Use `--follow` to stream logs in real time while debugging.