diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 83487a3ab33..6ddcc61df45 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -12,19 +12,19 @@ "src/lib/adapters/openshell/timeouts.ts": 38, "src/lib/agent/defs.ts": 33, "src/lib/cli/branding.ts": 86, - "src/lib/cli/nemoclaw-oclif-command.ts": 107, + "src/lib/cli/nemoclaw-oclif-command.ts": 106, "src/lib/cli/terminal-style.ts": 43, "src/lib/core/json-types.ts": 37, "src/lib/core/ports.ts": 89, "src/lib/core/shell-quote.ts": 28, "src/lib/core/url-utils.ts": 30, - "src/lib/core/wait.ts": 38, - "src/lib/credentials/store.ts": 46, + "src/lib/core/wait.ts": 37, + "src/lib/credentials/store.ts": 45, "src/lib/inference/config.ts": 30, "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 53, - "src/lib/runner.ts": 86, + "src/lib/runner.ts": 85, "src/lib/security/redact.ts": 54, "src/lib/state/onboard-session.ts": 36, "src/lib/state/mcp-lifecycle-lock.ts": 21, @@ -61,7 +61,7 @@ "allowedCycles": [], "maxRootFiles": { "src/lib/onboard": 308, - "src/lib/actions": 19, + "src/lib/actions": 18, "src/lib/actions/sandbox": 183, "src/lib/state": 39, "src/lib/inference": 63, diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8a3a425e8db..5421769c6b0 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1366,22 +1366,6 @@ If that cleanup cannot inspect, restore, or verify the mutable config permission When you want a shell inside the sandbox rather than an agent session, use `$$nemoclaw connect`. -### `$$nemoclaw deploy` - - -The `$$nemoclaw deploy` command is deprecated. -Prefer provisioning the remote host separately, then running the standard NemoClaw installer and `$$nemoclaw onboard` on that host. - - -Deploy NemoClaw to a remote GPU instance through [Brev](https://brev.nvidia.com). -This command remains as a compatibility wrapper for the older Brev-specific bootstrap flow. -The Brev instance name is the positional argument. -The sandbox name comes from `NEMOCLAW_SANDBOX_NAME` and defaults to `my-assistant`; invalid sandbox names fail before Brev provisioning starts. - -```bash -$$nemoclaw deploy -``` - ### `$$nemoclaw connect` Connect to a sandbox by name. @@ -5546,22 +5530,6 @@ The following flags change defaults for commands that manage existing sandboxes. | `NEMOCLAW_UNINSTALL_ALL_GATEWAY_PORTS` | `1` to opt in | Makes `$$nemoclaw uninstall` remove every gateway port on the host instead of only the port `NEMOCLAW_GATEWAY_PORT` selects. Equivalent to passing the `--all-gateway-ports` flag; the whole-host `Proceed?` confirmation still applies unless `--yes` is also passed. Each port runs as its own uninstall, and the variable is dropped from those runs so the sweep cannot re-enter itself. | | `NEMOCLAW_UNINSTALL_DESTROY_USER_DATA` | `1` to opt in | Acknowledges data loss during `$$nemoclaw uninstall`, skips eligible fresh sandbox backups, and removes the otherwise-preserved entries (`rebuild-backups/`, `backups/`, `sandboxes.json`) in the selected gateway's state root. It does not select the explicit `--destroy-user-data` CLI-shim removal path; shim handling follows the ordinary selected-gateway scope. The global `Proceed?` confirmation still applies unless `--yes` is also passed. | - -### Deprecated Brev Deployment - -The following variables configure the deprecated `$$nemoclaw deploy` compatibility command. -The maintained remote-server path does not use these variables. -For the current remote-server deployment path, refer to [Deploy to a Headless Server](../deployment/deploy-to-headless-server). - -| Variable | Default | Effect | -|----------|---------|--------| -| `NEMOCLAW_BREV_PROVIDER` | `gcp` | Cloud provider for Brev instance creation. | -| `NEMOCLAW_GPU` | `a2-highgpu-1g:nvidia-tesla-a100:1` | GPU specification (instance type and GPU model) for the Brev instance. | -| `NEMOCLAW_DEPLOY_NO_CONNECT` | unset | When set to `1`, skips the automatic `connect` step after the remote deploy completes. | -| `NEMOCLAW_DEPLOY_NO_START_SERVICES` | unset | When set to `1`, skips starting services automatically after the remote deploy. | - - - ### Legacy `$$nemoclaw setup` Deprecated. Use `$$nemoclaw onboard` instead. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 6f10c857734..c2b8f56b3c6 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1001,7 +1001,7 @@ Escaping can make the preview longer than 80 output characters. This prevents a rejected name from injecting control sequences into terminal or CI output. Names that collide with global CLI commands are also rejected. -Reserved names include `onboard`, `list`, `deploy`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`. +Reserved names include `onboard`, `list`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`. Using a reserved name would cause the CLI to route to the global command instead of the sandbox. If the name does not match these rules or is reserved, the wizard exits with an error. diff --git a/package.json b/package.json index 40b9c0311c3..7752544daa6 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,9 @@ "build:policy-boundary": "tsc -p nemoclaw/tsconfig.shared.json", "catalog:compile": "tsx src/lib/inference/serving/generate-catalog.ts", "catalog:check": "tsx src/lib/inference/serving/generate-catalog.ts --check", - "build:cli": "npm run build:policy-boundary && tsc -p tsconfig.src.json && node dist/lib/core/generate-build-identity.js && node dist/lib/inference/serving/generate-catalog.js && node dist/lib/cli/generate-oclif-metadata-manifest.js && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", + "build:cli": "npm run prune:retired-cli && npm run build:policy-boundary && tsc -p tsconfig.src.json && node dist/lib/core/generate-build-identity.js && node dist/lib/inference/serving/generate-catalog.js && node dist/lib/cli/generate-oclif-metadata-manifest.js && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", "clean:cli": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "prune:retired-cli": "node -e \"const fs = require('node:fs'); for (const prefix of ['dist/commands/deploy', 'dist/lib/actions/deploy']) for (const suffix of ['.js', '.js.map', '.d.ts', '.d.ts.map']) fs.rmSync(prefix + suffix, { force: true }); fs.rmSync('dist/lib/deploy', { recursive: true, force: true })\"", "typecheck:cli": "tsc -p tsconfig.cli.json", "validate:configs": "tsx scripts/validate-configs.mts", "generate:agent-state-lock-plans": "node --import tsx scripts/lib/generate-agent-state-lock-plans.mts", diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts deleted file mode 100644 index 0e7f24c9bc2..00000000000 --- a/src/commands/deploy.ts +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Args } from "@oclif/core"; -import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; - -import { runDeployAction } from "../lib/actions/global"; - -export default class DeployCliCommand extends NemoClawCommand { - static id = "deploy"; - static strict = true; - static summary = "Deprecated Brev-specific bootstrap path"; - static description = "Deprecated compatibility command for Brev-specific deployment."; - static usage = ["deploy [instance-name]"]; - static examples = ["<%= config.bin %> deploy my-gpu-instance"]; - static args = { - instanceName: Args.string({ - name: "instance-name", - description: "Brev instance name", - required: false, - }), - }; - static flags = {}; - - public async run(): Promise { - const { args } = await this.parse(DeployCliCommand); - await runDeployAction(args.instanceName); - } -} diff --git a/src/commands/simple-global-oclif-adapters.test.ts b/src/commands/simple-global-oclif-adapters.test.ts index c70c26cb894..81fde8dd8ad 100644 --- a/src/commands/simple-global-oclif-adapters.test.ts +++ b/src/commands/simple-global-oclif-adapters.test.ts @@ -38,7 +38,6 @@ const mocks = vi.hoisted(() => { listSandboxes: vi.fn(() => ({ sandboxes: [] })), resolveOpenshell: vi.fn(() => "/usr/bin/openshell"), runDebugCommandWithOptions: vi.fn(), - runDeployAction: vi.fn().mockResolvedValue(undefined), runDashboardUrlCommand: vi.fn(() => undefined), runGatewayTokenCommand: vi.fn(() => undefined), runStartCommand: vi.fn().mockResolvedValue(undefined), @@ -77,7 +76,6 @@ vi.mock("../lib/dashboard-url-command", () => ({ runDashboardUrlCommand: mocks.runDashboardUrlCommand, })); vi.mock("../lib/actions/global", () => ({ - runDeployAction: mocks.runDeployAction, showRootHelp: mocks.showRootHelp, showVersion: mocks.showVersion, })); @@ -112,7 +110,6 @@ vi.mock("../lib/state/mcp-lifecycle-lock-acquisition", async (importOriginal) => import { log } from "../lib/cli/logger"; import DebugCliCommand from "./debug"; -import DeployCliCommand from "./deploy"; import RootHelpCommand from "./root/help"; import VersionCommand from "./root/version"; import DashboardUrlCliCommand, { @@ -139,12 +136,11 @@ describe("simple global oclif adapters", testTimeoutOptions(30_000), () => { vi.restoreAllMocks(); }); - it("maps debug and deploy parser output to actions", async () => { + it("maps debug parser output to its action", async () => { await DebugCliCommand.run( ["--quick", "--output", "/tmp/debug.tar.gz", "--sandbox", "alpha"], rootDir, ); - await DeployCliCommand.run(["gpu-alpha"], rootDir); expect(mocks.runDebugCommandWithOptions).toHaveBeenCalledWith( { quick: true, output: "/tmp/debug.tar.gz", sandboxName: "alpha" }, @@ -153,7 +149,6 @@ describe("simple global oclif adapters", testTimeoutOptions(30_000), () => { runDebug: expect.any(Function), }), ); - expect(mocks.runDeployAction).toHaveBeenCalledWith("gpu-alpha"); }); it("keeps debug -q scoped to quick diagnostics instead of global quiet mode", async () => { diff --git a/src/lib/README.md b/src/lib/README.md index f9e18b7a358..2ace45a17cc 100644 --- a/src/lib/README.md +++ b/src/lib/README.md @@ -33,7 +33,6 @@ Some older modules are still too large or coupled to split directly into actions src/lib/agent/** agent definition, agent-specific onboarding, runtime helpers src/lib/credentials/** credential storage and credential command support src/lib/dashboard/** dashboard contract, health, and recovery helpers -src/lib/deploy/** deploy/build-image support that is not yet action-shaped src/lib/diagnostics/** debug collection and diagnostic report helpers src/lib/inference/** inference config, health probes, local runtime helpers src/lib/inventory/** list/status inventory shaping and presentation models diff --git a/src/lib/actions/deploy.ts b/src/lib/actions/deploy.ts deleted file mode 100644 index d1e331015de..00000000000 --- a/src/lib/actions/deploy.ts +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { execFileSync, spawnSync } from "node:child_process"; - -import { getCredential } from "../credentials/store"; -import { executeDeploy } from "../deploy"; -import { ROOT, run, runInteractive, shellQuote, validateName } from "../runner"; - -export async function runDeployAction(instanceName?: string): Promise { - await executeDeploy({ - instanceName, - env: process.env, - rootDir: ROOT, - getCredential, - validateName, - shellQuote, - run, - runInteractive, - execFileSync: ( - file: string, - args: string[], - opts: Omit< - import("node:child_process").ExecFileSyncOptionsWithStringEncoding, - "encoding" - > = {}, - ) => String(execFileSync(file, args, { encoding: "utf-8", ...opts })), - spawnSync, - log: console.log, - error: console.error, - stdoutWrite: (message: string) => process.stdout.write(message), - exit: (code: number) => process.exit(code), - }); -} diff --git a/src/lib/actions/global.test.ts b/src/lib/actions/global.test.ts index f241b4b0e0f..af75143323a 100644 --- a/src/lib/actions/global.test.ts +++ b/src/lib/actions/global.test.ts @@ -8,12 +8,10 @@ const mocks = vi.hoisted(() => ({ garbageCollectImages: vi.fn().mockResolvedValue(undefined), help: vi.fn(), recoverNamedGatewayRuntime: vi.fn().mockResolvedValue({ recovered: true }), - runDeployAction: vi.fn().mockResolvedValue(undefined), runOnboardAction: vi.fn().mockResolvedValue(undefined), version: vi.fn(), })); -vi.mock("./deploy", () => ({ runDeployAction: mocks.runDeployAction })); vi.mock("../gateway-runtime-action", () => ({ recoverNamedGatewayRuntime: mocks.recoverNamedGatewayRuntime, })); @@ -30,7 +28,6 @@ import { listManagedMcpCredentialReservations, recoverNamedGatewayRuntime, runBackupAllAction, - runDeployAction, runGarbageCollectImagesAction, runOnboardAction, runUpgradeSandboxesAction, @@ -45,17 +42,15 @@ describe("global cli action facade", () => { setGlobalCliActionRuntimeHooksForTest({}); }); - it("forwards onboarding, deploy, maintenance, and help actions", async () => { + it("forwards onboarding, maintenance, and help actions", async () => { const onboardRuntimeDeps = { googlechatTunnelRuntime: {} }; await runOnboardAction({ resume: true }, onboardRuntimeDeps); - await runDeployAction("gpu-alpha"); await runBackupAllAction(); await runGarbageCollectImagesAction({ dryRun: true }); showRootHelp(); showVersion(); expect(mocks.runOnboardAction).toHaveBeenCalledWith({ resume: true }, onboardRuntimeDeps); - expect(mocks.runDeployAction).toHaveBeenCalledWith("gpu-alpha"); expect(mocks.backupAll).toHaveBeenCalledWith(); expect(mocks.garbageCollectImages).toHaveBeenCalledWith({ dryRun: true }); expect(mocks.help).toHaveBeenCalledWith(); diff --git a/src/lib/actions/global.ts b/src/lib/actions/global.ts index 651685fb5ef..e8068ff097e 100644 --- a/src/lib/actions/global.ts +++ b/src/lib/actions/global.ts @@ -7,7 +7,6 @@ import { } from "../domain/lifecycle/options"; import { recoverNamedGatewayRuntime as recoverNamedGatewayRuntimeAction } from "../gateway-runtime-action"; import type { OnboardFlags } from "../onboard/command-support"; -import { runDeployAction as executeDeployAction } from "./deploy"; import { backupAll as executeBackupAllAction, garbageCollectImages as executeGarbageCollectImagesAction, @@ -44,10 +43,6 @@ export async function runOnboardAction( await executeOnboardAction(flags, runtimeDeps); } -export async function runDeployAction(instanceName?: string): Promise { - await executeDeployAction(instanceName); -} - export async function runBackupAllAction(): Promise { await executeBackupAllAction(); } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 207ff08a66d..fc8b58e2365 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -62,13 +62,6 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { flags: "[--quick] [--output FILE|-o FILE] [--sandbox NAME]", }, ], - deploy: [ - { - group: "Compatibility Commands", - order: 31, - deprecated: true, - }, - ], gc: [ { group: "Cleanup", diff --git a/src/lib/credentials/command-support.ts b/src/lib/credentials/command-support.ts index ca56ce08ccf..70fcc3551d7 100644 --- a/src/lib/credentials/command-support.ts +++ b/src/lib/credentials/command-support.ts @@ -22,7 +22,7 @@ export function printCredentialsUsage(log: (message?: string) => void = console. log(" reset [--yes] Remove a provider credential so onboard re-prompts"); log(""); log(" Credentials live in the OpenShell gateway. Inspect with `openshell provider list`."); - log(" Nothing is persisted to host disk; deploy/non-onboard commands read from env vars."); + log(" Nothing is persisted to host disk; credential registration reads values from env vars."); log(""); } diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 89cbafdbc9f..d2010c75661 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -52,7 +52,6 @@ export const KNOWN_CREDENTIAL_ENV_KEYS: readonly string[] = [ "GITHUB_TOKEN", "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", - "ALLOWED_CHAT_IDS", ...listMessagingCredentialMetadata().map((credential) => credential.providerEnvKey), ]; diff --git a/src/lib/deploy/README.md b/src/lib/deploy/README.md deleted file mode 100644 index 147658fd9f7..00000000000 --- a/src/lib/deploy/README.md +++ /dev/null @@ -1,8 +0,0 @@ - - - -# Deploy - -Deploy modules support remote/Brev compatibility flows and build-image setup that -has not yet been split into action/domain/adapter layers. Prefer new orchestration -in `src/lib/actions/**` and pure deploy planning helpers in `src/lib/domain/**`. diff --git a/src/lib/deploy/index.test.ts b/src/lib/deploy/index.test.ts deleted file mode 100644 index f6616687fc9..00000000000 --- a/src/lib/deploy/index.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; -import { validateName } from "../runner"; -import { - buildDeployEnvLines, - executeDeploy, - findBrevInstanceStatus, - inferDeployProvider, - isBrevInstanceFailed, - isBrevInstanceReady, - validateDeployInstanceName, -} from "./index"; - -describe("validateDeployInstanceName", () => { - it("preserves the Brev instance-name contract independently of sandbox limits (#8497)", () => { - const sixtyThreeCharacters = `a${"b".repeat(61)}z`; - - expect(validateDeployInstanceName("brev--instance-name-that-exceeds-nineteen")).toBe( - "brev--instance-name-that-exceeds-nineteen", - ); - expect(validateDeployInstanceName(sixtyThreeCharacters)).toBe(sixtyThreeCharacters); - expect(() => validateDeployInstanceName(`a${"b".repeat(63)}`)).toThrow( - /instance name too long \(max 63 chars\)/, - ); - }); -}); - -describe("inferDeployProvider", () => { - it("prefers an explicit provider override", () => { - const provider = inferDeployProvider("openai", { - NVIDIA_INFERENCE_API_KEY: "nvapi-test", - }); - - expect(provider).toBe("openai"); - }); - - it("infers the provider from a single matching credential", () => { - const provider = inferDeployProvider("", { - ANTHROPIC_API_KEY: "sk-ant-test", - }); - - expect(provider).toBe("anthropic"); - }); - - it("returns null when multiple provider credentials are present without an override", () => { - const provider = inferDeployProvider("", { - NVIDIA_INFERENCE_API_KEY: "nvapi-test", - OPENAI_API_KEY: "sk-openai-test", - }); - - expect(provider).toBeNull(); - }); -}); - -describe("buildDeployEnvLines", () => { - it("includes standard non-interactive deploy env plus passthrough values", () => { - const envLines = buildDeployEnvLines({ - env: { - CHAT_UI_URL: "https://chat.example.com", - NEMOCLAW_POLICY_MODE: "suggested", - }, - sandboxName: "my-assistant", - provider: "build", - credentials: { - NVIDIA_INFERENCE_API_KEY: "nvapi-test", - }, - shellQuote: (value: string) => `'${value}'`, - }); - - expect(envLines).toContain("NEMOCLAW_NON_INTERACTIVE=1"); - expect(envLines).toContain("NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1"); - expect(envLines).toContain("NEMOCLAW_SANDBOX_NAME='my-assistant'"); - expect(envLines).toContain("NEMOCLAW_PROVIDER='build'"); - expect(envLines).toContain("CHAT_UI_URL='https://chat.example.com'"); - expect(envLines).toContain("NEMOCLAW_POLICY_MODE='suggested'"); - expect(envLines).toContain("NVIDIA_INFERENCE_API_KEY='nvapi-test'"); - }); - - it("passes ALLOWED_CHAT_IDS through when Telegram is configured", () => { - const envLines = buildDeployEnvLines({ - env: {}, - sandboxName: "my-assistant", - provider: "build", - credentials: { - TELEGRAM_BOT_TOKEN: "123456:telegram-token", - ALLOWED_CHAT_IDS: "111,222", - }, - shellQuote: (value: string) => `'${value}'`, - }); - - expect(envLines).toContain("TELEGRAM_BOT_TOKEN='123456:telegram-token'"); - expect(envLines).toContain("ALLOWED_CHAT_IDS='111,222'"); - }); - - it("passes HF_TOKEN and HUGGING_FACE_HUB_TOKEN to the VM when set", () => { - const envLines = buildDeployEnvLines({ - env: {}, - sandboxName: "my-assistant", - provider: "build", - credentials: { - NVIDIA_INFERENCE_API_KEY: "nvapi-test", - HF_TOKEN: "hf_abc123", - HUGGING_FACE_HUB_TOKEN: "hf_def456", - }, - shellQuote: (value: string) => `'${value}'`, - }); - - expect(envLines).toContain("HF_TOKEN='hf_abc123'"); - expect(envLines).toContain("HUGGING_FACE_HUB_TOKEN='hf_def456'"); - }); - - it("omits ALLOWED_CHAT_IDS when Telegram is not configured", () => { - const envLines = buildDeployEnvLines({ - env: {}, - sandboxName: "my-assistant", - provider: "build", - credentials: { - ALLOWED_CHAT_IDS: "111,222", - }, - shellQuote: (value: string) => `'${value}'`, - }); - - expect(envLines).not.toContain("ALLOWED_CHAT_IDS='111,222'"); - }); -}); - -describe("executeDeploy", () => { - function makeDeployOptions(overrides: Partial[0]> = {}) { - const calls: Array<{ file?: string; args?: string[]; command?: readonly string[] }> = []; - const logs: string[] = []; - const errors: string[] = []; - const interactive: string[][] = []; - let plainBrevList = ""; - - const options: Parameters[0] = { - instanceName: "target", - env: { - NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", - NEMOCLAW_SANDBOX_NAME: "my-box", - }, - rootDir: "/repo/root", - getCredential: (key: string) => (key === "NVIDIA_INFERENCE_API_KEY" ? "nvapi-test" : null), - validateName: (value: string) => value, - shellQuote: (value: string) => `'${value}'`, - run: (command: readonly string[]) => { - calls.push({ command }); - }, - runInteractive: (command: readonly string[]) => { - interactive.push([...command]); - }, - execFileSync: (file: string, args: string[]) => { - calls.push({ file, args }); - if (file === "which" && args[0] === "brev") return ""; - if (file === "brev" && args[0] === "ls" && args[1] !== "--json") return plainBrevList; - if (file === "brev" && args[0] === "ls" && args[1] === "--json") { - return JSON.stringify([ - { - name: "target", - id: "brev-id-1", - status: "RUNNING", - build_status: "COMPLETED", - shell_status: "READY", - }, - ]); - } - if (file === "ssh" && args[0] === "-G") return "hostname target.example.test\n"; - if (file === "ssh" && args.includes("echo")) return "/home/tester\n"; - if (file === "ssh-keyscan") return "target.example.test ssh-ed25519 AAAA\n"; - return ""; - }, - spawnSync: () => undefined, - log: (message = "") => { - logs.push(message); - }, - error: (message = "") => { - errors.push(message); - }, - stdoutWrite: (message: string) => { - logs.push(message); - }, - exit: (code: number): never => { - throw new Error(`exit:${code}`); - }, - ...overrides, - }; - - return { - options, - calls, - logs, - errors, - interactive, - setPlainBrevList: (value: string) => (plainBrevList = value), - }; - } - - it("uses the standard installer, syncs a buildable checkout, pins SSH host keys, and connects to the requested sandbox", async () => { - const fixture = makeDeployOptions(); - - await executeDeploy(fixture.options); - - expect( - fixture.calls.some( - (call) => - call.command?.[0] === "brev" && - call.command.includes("create") && - call.command.includes("--provider") && - call.command.includes("gcp"), - ), - ).toBe(true); - const rsync = fixture.calls.find((call) => call.command?.[0] === "rsync")?.command ?? []; - expect(rsync).toContain("/repo/root/"); - expect(rsync).toContain("--exclude"); - expect(rsync).toContain("dist"); - expect(rsync).not.toContain("src"); - expect( - fixture.calls.some( - (call) => call.file === "ssh-keyscan" && call.args?.includes("target.example.test"), - ), - ).toBe(true); - const sshCommands = [ - ...fixture.calls.flatMap((call) => call.command ?? []), - ...fixture.interactive.flat(), - ]; - expect(sshCommands).toContain("StrictHostKeyChecking=yes"); - expect(sshCommands.some((arg) => String(arg).startsWith("UserKnownHostsFile="))).toBe(true); - expect(sshCommands).not.toContain("StrictHostKeyChecking=accept-new"); - expect( - fixture.interactive.some((command) => - command - .join(" ") - .includes( - "bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software", - ), - ), - ).toBe(true); - expect( - fixture.interactive.some((command) => - command.join(" ").includes("openshell sandbox connect 'my-box'"), - ), - ).toBe(true); - expect(fixture.logs.join("\n")).toContain("Skipping service startup"); - }); - - it("reports Brev failure states before SSH probing", async () => { - const fixture = makeDeployOptions({ - execFileSync: (file: string, args: string[]) => { - fixture.calls.push({ file, args }); - if (file === "which" && args[0] === "brev") return ""; - if (file === "brev" && args[0] === "ls" && args[1] !== "--json") return "target\n"; - if (file === "brev" && args[0] === "ls" && args[1] === "--json") { - return JSON.stringify([ - { - name: "target", - id: "failed-id", - status: "FAILURE", - build_status: "PENDING", - shell_status: "NOT_READY", - }, - ]); - } - throw new Error(`unexpected command: ${file} ${args.join(" ")}`); - }, - }); - - await expect(executeDeploy(fixture.options)).rejects.toThrow("exit:1"); - - const errorText = fixture.errors.join("\n"); - expect(errorText).toContain("Brev instance 'target' did not become ready."); - expect(errorText).toContain("Try: brev reset target"); - expect(errorText).toContain("failed-id"); - expect(fixture.calls.some((call) => call.file === "ssh-keyscan")).toBe(false); - }); - - it("rejects invalid NEMOCLAW_SANDBOX_NAME before Brev provisioning", async () => { - const fixture = makeDeployOptions({ - env: { - NEMOCLAW_SANDBOX_NAME: "bad name", - NEMOCLAW_PROVIDER: "build", - NEMOCLAW_DEPLOY_NO_START_SERVICES: "1", - }, - validateName, - }); - - await expect(executeDeploy(fixture.options)).rejects.toThrow("exit:1"); - - const errorText = fixture.errors.join("\n"); - expect(errorText).toContain('Invalid sandbox name: "bad name"'); - expect(errorText).toContain("Sandbox names cannot contain spaces."); - expect(errorText).toContain( - "Allowed format: 1-19 characters, lowercase, starts with a letter, letters/numbers/single internal hyphens only, ends with letter/number.", - ); - expect(errorText).toContain( - "Brev deploy is non-interactive and cannot prompt for a corrected sandbox name.", - ); - expect(errorText).toContain("Set NEMOCLAW_SANDBOX_NAME to a valid sandbox name and retry."); - expect(fixture.calls).toEqual([]); - expect(fixture.interactive).toEqual([]); - }); -}); - -describe("Brev status helpers", () => { - it("finds the matching instance from brev ls json", () => { - const status = findBrevInstanceStatus( - JSON.stringify([ - { name: "other", status: "RUNNING" }, - { name: "target", status: "FAILURE", build_status: "PENDING", shell_status: "NOT READY" }, - ]), - "target", - ); - - expect(status).toMatchObject({ - name: "target", - status: "FAILURE", - build_status: "PENDING", - shell_status: "NOT READY", - }); - }); - - it("classifies Brev failure states", () => { - expect( - isBrevInstanceFailed({ - status: "FAILURE", - build_status: "PENDING", - shell_status: "NOT READY", - }), - ).toBe(true); - expect( - isBrevInstanceFailed({ - status: "RUNNING", - build_status: "COMPLETED", - shell_status: "READY", - }), - ).toBe(false); - }); - - it("only classifies Brev readiness when running, completed, and ready", () => { - expect( - isBrevInstanceReady({ - status: "RUNNING", - build_status: "COMPLETED", - shell_status: "READY", - }), - ).toBe(true); - expect( - isBrevInstanceReady({ - status: "RUNNING", - build_status: "BUILDING", - shell_status: "NOT READY", - }), - ).toBe(false); - }); -}); diff --git a/src/lib/deploy/index.ts b/src/lib/deploy/index.ts deleted file mode 100644 index e8ee88307f7..00000000000 --- a/src/lib/deploy/index.ts +++ /dev/null @@ -1,571 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { sleepSeconds } from "../core/wait"; -import { - diagnosticPreview, - getNameValidationGuidance, - NAME_ALLOWED_FORMAT, -} from "../name-validation"; - -const DEPLOY_INSTANCE_NAME_MAX_LENGTH = 63; -const DEPLOY_INSTANCE_NAME_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; -const DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT = - "1-63 characters, lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number"; - -// Brev instance names are not OpenShell sandbox identities. Preserve their -// established RFC-compatible 63-character boundary while sandbox names use -// the stricter OpenShell 0.0.99 contract through the injected validator. -export function validateDeployInstanceName(name: string): string { - if (!name || typeof name !== "string") { - throw new Error( - `instance name is required. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - if (name.length > DEPLOY_INSTANCE_NAME_MAX_LENGTH) { - throw new Error( - `instance name too long (max ${DEPLOY_INSTANCE_NAME_MAX_LENGTH} chars): ${diagnosticPreview(name)}. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - if (!DEPLOY_INSTANCE_NAME_PATTERN.test(name)) { - throw new Error( - `Invalid instance name: ${diagnosticPreview(name)}. Allowed format: ${DEPLOY_INSTANCE_NAME_ALLOWED_FORMAT}.`, - ); - } - return name; -} - -type ExecLikeValue = - | string - | number - | boolean - | null - | undefined - | string[] - | NodeJS.ProcessEnv - | object; -type ExecLikeOptions = { [key: string]: ExecLikeValue }; - -function readCommandOutput(error: object | null, key: "stdout" | "stderr"): string { - if (error === null) { - return ""; - } - const value = Reflect.get(error, key); - return typeof value === "string" ? value : String(value || ""); -} - -export interface DeployCredentials { - NVIDIA_INFERENCE_API_KEY?: string | null; - OPENAI_API_KEY?: string | null; - ANTHROPIC_API_KEY?: string | null; - GEMINI_API_KEY?: string | null; - COMPATIBLE_API_KEY?: string | null; - COMPATIBLE_ANTHROPIC_API_KEY?: string | null; - GITHUB_TOKEN?: string | null; - HF_TOKEN?: string | null; - HUGGING_FACE_HUB_TOKEN?: string | null; - TELEGRAM_BOT_TOKEN?: string | null; - ALLOWED_CHAT_IDS?: string | null; - DISCORD_BOT_TOKEN?: string | null; - SLACK_BOT_TOKEN?: string | null; -} - -export interface BrevInstanceStatus { - name?: string; - id?: string; - status?: string; - build_status?: string; - shell_status?: string; - health_status?: string; - instance_type?: string; - instance_kind?: string; - gpu?: string; -} - -export interface DeployExecutionOptions { - instanceName?: string; - env: NodeJS.ProcessEnv; - rootDir: string; - getCredential: (key: string) => string | null; - validateName: (value: string, label: string) => string; - shellQuote: (value: string) => string; - run: (command: readonly string[], opts?: { ignoreError?: boolean }) => void; - runInteractive: (command: readonly string[]) => void; - execFileSync: (file: string, args: string[], opts?: ExecLikeOptions) => string; - spawnSync: (file: string, args: string[], opts?: ExecLikeOptions) => void; - log: (message?: string) => void; - error: (message?: string) => void; - stdoutWrite: (message: string) => void; - exit: (code: number) => never; -} - -// SSH host key verification helper — resolves the real hostname from SSH config -// (brev aliases aren't DNS-resolvable) and returns it for ssh-keyscan. -export function resolveRealHost( - name: string, - execFileSync: DeployExecutionOptions["execFileSync"], -): string { - const sshConfigOut = execFileSync("ssh", ["-G", name], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }); - return ( - sshConfigOut - .split("\n") - .find((l) => l.startsWith("hostname ")) - ?.split(" ")[1] || name - ); -} - -// Build SSH options that enforce strict host key checking against a pinned known_hosts file. -export function buildSshOpts(knownHostsFile: string, shellQuote: (v: string) => string): string { - return `-o UserKnownHostsFile=${shellQuote(knownHostsFile)} -o StrictHostKeyChecking=yes -o LogLevel=ERROR`; -} - -// Build SSH argument array for execFileSync calls with pinned host key verification. -export function buildSshArgs(knownHostsFile: string): string[] { - return [ - "-o", - `UserKnownHostsFile=${knownHostsFile}`, - "-o", - "StrictHostKeyChecking=yes", - "-o", - "LogLevel=ERROR", - ]; -} - -export function inferDeployProvider( - explicitProvider: string | undefined, - credentials: DeployCredentials, -): string | null { - const explicit = String(explicitProvider || "") - .trim() - .toLowerCase(); - if (explicit) return explicit; - - const providerByCredential: Array<[keyof DeployCredentials, string]> = [ - ["NVIDIA_INFERENCE_API_KEY", "build"], - ["OPENAI_API_KEY", "openai"], - ["ANTHROPIC_API_KEY", "anthropic"], - ["GEMINI_API_KEY", "gemini"], - ["COMPATIBLE_API_KEY", "custom"], - ["COMPATIBLE_ANTHROPIC_API_KEY", "anthropicCompatible"], - ]; - const matches = providerByCredential.filter(([key]) => credentials[key]); - if (matches.length === 1) return matches[0][1]; - return null; -} - -export function buildDeployEnvLines(opts: { - env: NodeJS.ProcessEnv; - sandboxName: string; - provider: string; - credentials: DeployCredentials; - shellQuote: (value: string) => string; -}): string[] { - const { env, sandboxName, provider, credentials, shellQuote } = opts; - const envLines = [ - "NEMOCLAW_NON_INTERACTIVE=1", - "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1", - `NEMOCLAW_SANDBOX_NAME=${shellQuote(sandboxName)}`, - `NEMOCLAW_PROVIDER=${shellQuote(provider)}`, - ]; - - const passthroughVars = [ - "NEMOCLAW_MODEL", - "NEMOCLAW_ENDPOINT_URL", - "NEMOCLAW_POLICY_MODE", - "NEMOCLAW_POLICY_PRESETS", - "CHAT_UI_URL", - ]; - for (const key of passthroughVars) { - const value = env[key]; - if (value) envLines.push(`${key}=${shellQuote(value)}`); - } - - for (const [key, value] of Object.entries(credentials)) { - if (!value || key === "ALLOWED_CHAT_IDS") continue; - envLines.push(`${key}=${shellQuote(value)}`); - } - - if (credentials.TELEGRAM_BOT_TOKEN && credentials.ALLOWED_CHAT_IDS) { - envLines.push(`ALLOWED_CHAT_IDS=${shellQuote(credentials.ALLOWED_CHAT_IDS)}`); - } - - return envLines; -} - -function outputHasExactLine(output: string | undefined, expected: string): boolean { - return String(output || "") - .split(/\r?\n/) - .some((line) => line.trim() === expected); -} - -export function findBrevInstanceStatus( - rawJson: string, - instanceName: string, -): BrevInstanceStatus | null { - try { - const items = JSON.parse(rawJson); - if (!Array.isArray(items)) return null; - const match = items.find( - (item): item is BrevInstanceStatus => - typeof item === "object" && item !== null && item.name === instanceName, - ); - return match ?? null; - } catch { - return null; - } -} - -export function isBrevInstanceFailed(status: BrevInstanceStatus | null): boolean { - if (!status) return false; - const overall = String(status.status || "").toUpperCase(); - const build = String(status.build_status || "").toUpperCase(); - return overall === "FAILURE" || build === "FAILURE"; -} - -export function isBrevInstanceReady(status: BrevInstanceStatus | null): boolean { - if (!status) return false; - const overall = String(status.status || "").toUpperCase(); - const build = String(status.build_status || "").toUpperCase(); - const shell = String(status.shell_status || "").toUpperCase(); - return overall === "RUNNING" && build === "COMPLETED" && shell === "READY"; -} - -function getBrevInstanceStatus( - instanceName: string, - execFileSync: DeployExecutionOptions["execFileSync"], -): BrevInstanceStatus | null { - try { - const raw = execFileSync("brev", ["ls", "--json"], { encoding: "utf-8" }); - return findBrevInstanceStatus(raw, instanceName); - } catch { - return null; - } -} - -function fail( - lines: string[], - error: DeployExecutionOptions["error"], - exit: DeployExecutionOptions["exit"], -): never { - for (const line of lines) error(line); - return exit(1); -} - -function validateDeploySandboxName( - rawSandboxName: string, - opts: Pick, -): string { - try { - return opts.validateName(rawSandboxName, "sandbox name"); - } catch (caught) { - const message = caught instanceof Error ? caught.message : String(caught); - return fail( - [ - ` ${message}`, - ...getNameValidationGuidance("sandbox name", rawSandboxName, { - includeAllowedFormat: false, - }).map((line) => ` ${line}`), - " Brev deploy is non-interactive and cannot prompt for a corrected sandbox name.", - " Set NEMOCLAW_SANDBOX_NAME to a valid sandbox name and retry.", - ], - opts.error, - opts.exit, - ); - } -} - -export async function executeDeploy(opts: DeployExecutionOptions): Promise { - const { - instanceName, - env, - rootDir, - getCredential, - validateName, - shellQuote, - run, - runInteractive, - execFileSync, - log, - error, - stdoutWrite, - exit, - } = opts; - - log(""); - log(" ⚠ `nemoclaw deploy` is deprecated and will be removed in a future release."); - log( - " Prefer provisioning the remote host separately, then run the standard installer and `nemoclaw onboard` on that host.", - ); - log(""); - if (!instanceName) { - return fail( - [ - " Usage: nemoclaw deploy ", - "", - " Examples:", - " nemoclaw deploy my-gpu-box", - " nemoclaw deploy nemoclaw-prod", - " nemoclaw deploy nemoclaw-test", - "", - " Sandbox name comes from NEMOCLAW_SANDBOX_NAME (default: my-assistant).", - ` Allowed sandbox name format: ${NAME_ALLOWED_FORMAT}.`, - ], - error, - exit, - ); - } - - const name = validateDeployInstanceName(instanceName); - const gpu = env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; - const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp") - .trim() - .toLowerCase(); - const skipConnect = ["1", "true"].includes( - String(env.NEMOCLAW_DEPLOY_NO_CONNECT || "").toLowerCase(), - ); - const skipStartServices = ["1", "true"].includes( - String(env.NEMOCLAW_DEPLOY_NO_START_SERVICES || "").toLowerCase(), - ); - const sandboxName = validateDeploySandboxName(env.NEMOCLAW_SANDBOX_NAME || "my-assistant", { - validateName, - error, - exit, - }); - const credentials: DeployCredentials = { - NVIDIA_INFERENCE_API_KEY: getCredential("NVIDIA_INFERENCE_API_KEY"), - OPENAI_API_KEY: getCredential("OPENAI_API_KEY"), - ANTHROPIC_API_KEY: getCredential("ANTHROPIC_API_KEY"), - GEMINI_API_KEY: getCredential("GEMINI_API_KEY"), - COMPATIBLE_API_KEY: getCredential("COMPATIBLE_API_KEY"), - COMPATIBLE_ANTHROPIC_API_KEY: getCredential("COMPATIBLE_ANTHROPIC_API_KEY"), - GITHUB_TOKEN: getCredential("GITHUB_TOKEN"), - HF_TOKEN: getCredential("HF_TOKEN"), - HUGGING_FACE_HUB_TOKEN: getCredential("HUGGING_FACE_HUB_TOKEN"), - TELEGRAM_BOT_TOKEN: getCredential("TELEGRAM_BOT_TOKEN"), - ALLOWED_CHAT_IDS: getCredential("ALLOWED_CHAT_IDS"), - DISCORD_BOT_TOKEN: getCredential("DISCORD_BOT_TOKEN"), - SLACK_BOT_TOKEN: getCredential("SLACK_BOT_TOKEN"), - }; - const provider = inferDeployProvider(env.NEMOCLAW_PROVIDER, credentials); - if (!provider) { - return fail( - [ - " Could not determine which inference provider to configure for remote onboarding.", - " Set `NEMOCLAW_PROVIDER` explicitly or provide exactly one matching provider credential.", - " Supported provider credentials: NVIDIA_INFERENCE_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, COMPATIBLE_API_KEY, COMPATIBLE_ANTHROPIC_API_KEY.", - ], - error, - exit, - ); - } - - log(""); - log(` Deploying NemoClaw to Brev instance: ${name}`); - log(""); - - try { - execFileSync("which", ["brev"], { stdio: "ignore" }); - } catch { - return fail(["brev CLI not found. Install: https://brev.nvidia.com"], error, exit); - } - - let exists = false; - try { - const out = execFileSync("brev", ["ls"], { encoding: "utf-8" }); - exists = outputHasExactLine(out, name); - } catch (caught) { - const caughtObject = typeof caught === "object" && caught !== null ? caught : null; - if (outputHasExactLine(readCommandOutput(caughtObject, "stdout"), name)) exists = true; - if (outputHasExactLine(readCommandOutput(caughtObject, "stderr"), name)) exists = true; - } - - if (!exists) { - log(` Creating Brev instance '${name}' (${gpu}, provider=${brevProvider})...`); - run(["brev", "create", name, "--type", gpu, "--provider", brevProvider]); - } else { - log(` Brev instance '${name}' already exists.`); - } - - run(["brev", "refresh"], { ignoreError: true }); - - stdoutWrite(" Waiting for Brev instance readiness "); - for (let i = 0; i < 60; i++) { - const brevStatus = getBrevInstanceStatus(name, execFileSync); - if (isBrevInstanceFailed(brevStatus)) { - stdoutWrite("\n"); - error(` Brev instance '${name}' did not become ready.`); - error( - ` Brev status: status=${brevStatus?.status || "unknown"} build=${brevStatus?.build_status || "unknown"} shell=${brevStatus?.shell_status || "unknown"}`, - ); - if (brevStatus?.id) error(` Instance id: ${brevStatus.id}`); - return fail([` Try: brev reset ${name}`], error, exit); - } - if (isBrevInstanceReady(brevStatus)) { - stdoutWrite(" ✓\n"); - break; - } - - if (i === 59) { - stdoutWrite("\n"); - const finalBrevStatus = getBrevInstanceStatus(name, execFileSync); - if (finalBrevStatus) { - error( - ` Brev status at timeout: status=${finalBrevStatus.status || "unknown"} build=${finalBrevStatus.build_status || "unknown"} shell=${finalBrevStatus.shell_status || "unknown"}`, - ); - if (finalBrevStatus.id) error(` Instance id: ${finalBrevStatus.id}`); - } - return fail([` Timed out waiting for Brev instance readiness for ${name}`], error, exit); - } - stdoutWrite("."); - sleepSeconds(3); - } - - // ── SSH trust-on-first-use (TOFU) ────────────────────────────── - // Pin the host key on first contact via ssh-keyscan, then verify all - // subsequent connections against it. We keyscan first (not a probe with - // StrictHostKeyChecking=no) to avoid a TOCTOU window where an attacker - // could interpose between an unauthenticated probe and key capture. - // Ref: https://github.com/NVIDIA/NemoClaw/issues/691 - const khDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ssh-")); - const knownHostsFile = path.join(khDir, "known_hosts"); - const realHost = resolveRealHost(name, execFileSync); - - stdoutWrite(" Waiting for SSH "); - for (let i = 0; i < 60; i++) { - try { - const hostKeys = execFileSync("ssh-keyscan", ["-T", "5", "-H", realHost], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (hostKeys.trim()) { - fs.writeFileSync(knownHostsFile, hostKeys, { mode: 0o600 }); - stdoutWrite(" ✓\n"); - break; - } - } catch { - /* keyscan failed, retry */ - } - if (i === 59) { - stdoutWrite("\n"); - fs.rmSync(khDir, { recursive: true, force: true }); - return fail( - [` Timed out waiting for SSH to ${name} (keyscan failed after 60 attempts)`], - error, - exit, - ); - } - stdoutWrite("."); - sleepSeconds(3); - } - - const sshOpts = buildSshOpts(knownHostsFile, shellQuote); - const sshArgs = buildSshArgs(knownHostsFile); - - try { - const remoteHome = execFileSync("ssh", [...sshArgs, name, "echo", "$HOME"], { - encoding: "utf-8", - }).trim(); - const remoteDir = `${remoteHome}/nemoclaw`; - - log(" Syncing NemoClaw to VM..."); - run(["ssh", ...sshArgs, name, `mkdir -p ${shellQuote(remoteDir)}`]); - run([ - "rsync", - "-az", - "--delete", - "--exclude", - "node_modules", - "--exclude", - ".git", - "--exclude", - "dist", - "--exclude", - ".venv", - "-e", - `ssh ${sshOpts}`, - `${rootDir}/`, - `${name}:${remoteDir}/`, - ]); - - const envLines = buildDeployEnvLines({ - env, - sandboxName, - provider, - credentials, - shellQuote, - }); - const envDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-")); - const envTmp = path.join(envDir, "env"); - fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); - try { - run(["scp", "-q", ...sshArgs, envTmp, `${name}:${remoteDir}/.env`]); - run(["ssh", "-q", ...sshArgs, name, `chmod 600 ${shellQuote(`${remoteDir}/.env`)}`]); - } finally { - try { - fs.unlinkSync(envTmp); - } catch { - /* ignored */ - } - try { - fs.rmdirSync(envDir); - } catch { - /* ignored */ - } - } - - log(" Running setup..."); - runInteractive([ - "ssh", - "-t", - ...sshArgs, - name, - `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/install.sh --non-interactive --yes-i-accept-third-party-software`, - ]); - - if ( - !skipStartServices && - (credentials.TELEGRAM_BOT_TOKEN || - credentials.DISCORD_BOT_TOKEN || - credentials.SLACK_BOT_TOKEN) - ) { - log(" Starting services..."); - run([ - "ssh", - ...sshArgs, - name, - `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && bash scripts/start-services.sh`, - ]); - } - - if (skipStartServices) { - log(" Skipping service startup (NEMOCLAW_DEPLOY_NO_START_SERVICES=1)."); - } - - if (skipConnect) { - log(""); - log(" Skipping interactive sandbox connect (NEMOCLAW_DEPLOY_NO_CONNECT=1)."); - log(` Remote sandbox: ${sandboxName}`); - log(` Connect later with: ssh ${name} 'openshell sandbox connect ${sandboxName}'`); - return; - } - - log(""); - log(" Connecting to sandbox..."); - log(""); - runInteractive([ - "ssh", - "-t", - ...sshArgs, - name, - `cd ${shellQuote(remoteDir)} && set -a && . .env && set +a && openshell sandbox connect ${shellQuote(sandboxName)}`, - ]); - } finally { - fs.rmSync(khDir, { recursive: true, force: true }); - } -} diff --git a/src/lib/onboard/entry-options.test.ts b/src/lib/onboard/entry-options.test.ts index ae40e6f3bc9..d2446a03494 100644 --- a/src/lib/onboard/entry-options.test.ts +++ b/src/lib/onboard/entry-options.test.ts @@ -178,6 +178,23 @@ describe("resolveOnboardEntryOptions", () => { expect(deps.exitProcess).toHaveBeenCalledTimes(1); }); + it("accepts deploy as a sandbox name after the command is removed (#10572)", () => { + const deps = createDeps(); + + const result = resolveOnboardEntryOptions( + { + opts: { sandboxName: "Deploy" }, + env: {}, + stdinIsTty: true, + stdoutIsTty: true, + }, + deps, + ); + + expect(result.requestedSandboxName).toBe("deploy"); + expect(deps.error).not.toHaveBeenCalled(); + }); + it("auto-detects resume from a persisted in_progress session without --resume (#5470)", () => { const deps = createDeps(); diff --git a/src/lib/onboard/sandbox-agent.ts b/src/lib/onboard/sandbox-agent.ts index 271f1a279bd..1b92569f246 100644 --- a/src/lib/onboard/sandbox-agent.ts +++ b/src/lib/onboard/sandbox-agent.ts @@ -18,7 +18,6 @@ import * as registry from "../state/registry"; export const RESERVED_SANDBOX_NAMES = new Set([ "onboard", "list", - "deploy", "setup", "setup-spark", "start", diff --git a/test/cli/onboard-compatibility.test.ts b/test/cli/onboard-compatibility.test.ts index bfabcaaafa7..c689c0b5253 100644 --- a/test/cli/onboard-compatibility.test.ts +++ b/test/cli/onboard-compatibility.test.ts @@ -334,10 +334,11 @@ describe("CLI onboard compatibility", () => { ); }); - it("deploy --help exits 0 and shows deprecated usage", () => { + it("treats deploy as a sandbox name after the command is removed (#10572)", () => { const r = run("deploy --help"); expect(r.code).toBe(0); - expect(r.out).toContain("deploy [instance-name]"); - expect(r.out).toContain("Deprecated Brev-specific bootstrap path"); + expect(r.out).toContain("Usage: nemoclaw deploy connect [--probe-only]"); + expect(r.out).not.toContain("deploy [instance-name]"); + expect(r.out).not.toContain("Deprecated Brev-specific bootstrap path"); }); }); diff --git a/test/credentials/credentials.test.ts b/test/credentials/credentials.test.ts index 1cfbaeb832d..561ac9d8b44 100644 --- a/test/credentials/credentials.test.ts +++ b/test/credentials/credentials.test.ts @@ -30,7 +30,14 @@ function isCredentialsModule(value: object | null): value is CredentialsModule { // fixture-only names this suite mutates directly. import { KNOWN_CREDENTIAL_ENV_KEYS } from "../../src/lib/credentials/store.js"; -const TEST_FIXTURE_ENV_KEYS = ["TEST_API_KEY", "OTHER_KEY", "EMPTY_VALUE", "ZETA", "ALPHA"]; +const TEST_FIXTURE_ENV_KEYS = [ + "TEST_API_KEY", + "OTHER_KEY", + "EMPTY_VALUE", + "ZETA", + "ALPHA", + "ALLOWED_CHAT_IDS", +]; const TRACKED_ENV_KEYS = [...KNOWN_CREDENTIAL_ENV_KEYS, ...TEST_FIXTURE_ENV_KEYS]; function clearTrackedEnv() { @@ -73,13 +80,6 @@ afterEach(() => { }); describe("messaging legacy bridge credentials", () => { - it("keeps the legacy ALLOWED_CHAT_IDS entry for the deploy-time bridge", () => { - // The Telegram bridge runtime injected by deploy.ts still expects the - // legacy env name. Channel config values are persisted separately from - // provider credentials, but this credential key stays for deploy.ts. - expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("ALLOWED_CHAT_IDS"); - }); - it("registers WECHAT_BOT_TOKEN alongside the other channel bot tokens", () => { // The WeChat host-QR onboarding writes the captured token via // saveCredential("WECHAT_BOT_TOKEN", ...). If this key is missing from @@ -279,6 +279,24 @@ describe("legacy credentials.json migration (two-phase: stage then remove)", () expect(fs.existsSync(legacyFile)).toBe(true); }); + it("does not stage or retain the retired deploy credential (#10572)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credsDir = path.join(home, ".nemoclaw"); + const legacyFile = path.join(credsDir, "credentials.json"); + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(legacyFile, JSON.stringify({ ALLOWED_CHAT_IDS: "111,222" }), { + mode: 0o600, + }); + + const credentials = await importCredentialsModule(home); + + expect(credentials.stageLegacyCredentialsToEnv()).toEqual([]); + expect(process.env.ALLOWED_CHAT_IDS).toBeUndefined(); + expect(fs.existsSync(legacyFile)).toBe(true); + expect(credentials.removeLegacyCredentialsFileIfEmpty()).toBe(true); + expect(fs.existsSync(legacyFile)).toBe(false); + }); + it("ignores keys outside the credential allowlist (PATH, NODE_OPTIONS, etc.)", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); const credsDir = path.join(home, ".nemoclaw"); diff --git a/test/package-contract/cli/build-upgrade.test.ts b/test/package-contract/cli/build-upgrade.test.ts new file mode 100644 index 00000000000..35e0bd0fdd4 --- /dev/null +++ b/test/package-contract/cli/build-upgrade.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); +const PREVIOUS_COMMAND_ARTIFACT = "dist/commands/deploy.js"; +const PREVIOUS_COMMAND_DECLARATION = "dist/commands/deploy.d.ts"; +const PREVIOUS_COMMAND_SOURCE_MAP = "dist/commands/deploy.js.map"; +const PREVIOUS_ACTION_ARTIFACT = "dist/lib/actions/deploy.js"; +const PREVIOUS_ACTION_DECLARATION_MAP = "dist/lib/actions/deploy.d.ts.map"; +const PREVIOUS_IMPLEMENTATION_ARTIFACT = "dist/lib/deploy/index.js"; + +describe("CLI source-checkout upgrade build", () => { + it("prunes compiled deploy artifacts before the normal build (#10572)", () => { + const fixtureRoot = mkdtempSync(path.join(tmpdir(), "nemoclaw-cli-upgrade-build-")); + try { + copyFileSync( + path.join(REPOSITORY_ROOT, "package.json"), + path.join(fixtureRoot, "package.json"), + ); + copyFileSync( + path.join(REPOSITORY_ROOT, "tsconfig.src.json"), + path.join(fixtureRoot, "tsconfig.src.json"), + ); + writeFileSync(path.join(fixtureRoot, ".source-revision"), `${"a".repeat(40)}\n`); + + symlinkSync(path.join(REPOSITORY_ROOT, "bin"), path.join(fixtureRoot, "bin"), "junction"); + symlinkSync( + path.join(REPOSITORY_ROOT, "managed-inference"), + path.join(fixtureRoot, "managed-inference"), + "junction", + ); + symlinkSync( + path.join(REPOSITORY_ROOT, "node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + symlinkSync(path.join(REPOSITORY_ROOT, "src"), path.join(fixtureRoot, "src"), "junction"); + + const policyRoot = path.join(fixtureRoot, "nemoclaw"); + mkdirSync(policyRoot); + copyFileSync( + path.join(REPOSITORY_ROOT, "nemoclaw", "tsconfig.json"), + path.join(policyRoot, "tsconfig.json"), + ); + copyFileSync( + path.join(REPOSITORY_ROOT, "nemoclaw", "tsconfig.shared.json"), + path.join(policyRoot, "tsconfig.shared.json"), + ); + symlinkSync( + path.join(REPOSITORY_ROOT, "nemoclaw", "node_modules"), + path.join(policyRoot, "node_modules"), + "junction", + ); + symlinkSync( + path.join(REPOSITORY_ROOT, "nemoclaw", "src"), + path.join(policyRoot, "src"), + "junction", + ); + + const blueprintRoot = path.join(fixtureRoot, "nemoclaw-blueprint"); + mkdirSync(blueprintRoot); + copyFileSync( + path.join(REPOSITORY_ROOT, "nemoclaw-blueprint", "tsconfig.json"), + path.join(blueprintRoot, "tsconfig.json"), + ); + symlinkSync( + path.join(REPOSITORY_ROOT, "nemoclaw-blueprint", "scripts"), + path.join(blueprintRoot, "scripts"), + "junction", + ); + + const previousCommandPath = path.join(fixtureRoot, PREVIOUS_COMMAND_ARTIFACT); + const previousCommandDeclarationPath = path.join(fixtureRoot, PREVIOUS_COMMAND_DECLARATION); + const previousCommandSourceMapPath = path.join(fixtureRoot, PREVIOUS_COMMAND_SOURCE_MAP); + const previousActionPath = path.join(fixtureRoot, PREVIOUS_ACTION_ARTIFACT); + const previousActionDeclarationMapPath = path.join( + fixtureRoot, + PREVIOUS_ACTION_DECLARATION_MAP, + ); + const previousImplementationPath = path.join(fixtureRoot, PREVIOUS_IMPLEMENTATION_ARTIFACT); + mkdirSync(path.dirname(previousCommandPath), { recursive: true }); + mkdirSync(path.dirname(previousActionPath), { recursive: true }); + mkdirSync(path.dirname(previousImplementationPath), { recursive: true }); + writeFileSync(previousCommandPath, "module.exports = {};\n"); + writeFileSync(previousCommandDeclarationPath, "export {};\n"); + writeFileSync(previousCommandSourceMapPath, "{}\n"); + writeFileSync(previousActionPath, "module.exports = {};\n"); + writeFileSync(previousActionDeclarationMapPath, "{}\n"); + writeFileSync(previousImplementationPath, "module.exports = {};\n"); + + const staleMetadataPath = path.join( + fixtureRoot, + "dist/lib/cli/oclif-command-metadata.generated.json", + ); + mkdirSync(path.dirname(staleMetadataPath), { recursive: true }); + writeFileSync( + staleMetadataPath, + `${JSON.stringify({ deploy: { id: "deploy", summary: "Deprecated Brev command" } })}\n`, + ); + + const npmExecutable = process.platform === "win32" ? "npm.cmd" : "npm"; + const build = spawnSync(npmExecutable, ["run", "build:cli"], { + cwd: fixtureRoot, + encoding: "utf8", + env: process.env, + timeout: 120_000, + }); + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0); + + expect(existsSync(previousCommandPath), PREVIOUS_COMMAND_ARTIFACT).toBe(false); + expect(existsSync(previousCommandDeclarationPath), PREVIOUS_COMMAND_DECLARATION).toBe(false); + expect(existsSync(previousCommandSourceMapPath), PREVIOUS_COMMAND_SOURCE_MAP).toBe(false); + expect(existsSync(previousActionPath), PREVIOUS_ACTION_ARTIFACT).toBe(false); + expect(existsSync(previousActionDeclarationMapPath), PREVIOUS_ACTION_DECLARATION_MAP).toBe( + false, + ); + expect(existsSync(previousImplementationPath), PREVIOUS_IMPLEMENTATION_ARTIFACT).toBe(false); + const routing = spawnSync( + process.execPath, + [ + "-e", + "const registry = require('./dist/lib/cli/command-registry'); process.stdout.write(String(registry.globalCommandTokens().has('deploy')))", + ], + { cwd: fixtureRoot, encoding: "utf8", env: process.env }, + ); + expect(routing.status, routing.stderr).toBe(0); + expect(routing.stdout).toBe("false"); + + const help = spawnSync(process.execPath, ["bin/nemoclaw.js", "deploy", "--help"], { + cwd: fixtureRoot, + encoding: "utf8", + env: { + ...process.env, + HOME: path.join(fixtureRoot, "home"), + NEMOCLAW_DISABLE_GATEWAY_DRIFT_PREFLIGHT: "1", + }, + timeout: 30_000, + }); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain("Usage: nemoclaw deploy connect"); + expect(help.stdout).not.toContain("Brev-specific"); + } finally { + rmSync(fixtureRoot, { force: true, recursive: true }); + } + }, 150_000); +}); diff --git a/test/package-contract/cli/command-registry.test.ts b/test/package-contract/cli/command-registry.test.ts index acfc0ad20e9..506b2b80a6b 100644 --- a/test/package-contract/cli/command-registry.test.ts +++ b/test/package-contract/cli/command-registry.test.ts @@ -121,17 +121,21 @@ describe("command-registry", () => { const discoveredIds = new Set(Object.keys(getRegisteredOclifCommandsMetadata())); expect(discoveredIds.has(command.commandId), command.usage).toBe(true); }); + + it("does not discover the removed deploy command (#10572)", () => { + expect(getRegisteredOclifCommandsMetadata()).not.toHaveProperty("deploy"); + }); }); describe("deprecated commands", () => { - it("should include setup, setup-spark, deploy, start, stop", () => { + it("includes the remaining compatibility commands and excludes deploy (#10572)", () => { const deprecated = COMMANDS.filter((c) => c.deprecated); const usages = deprecated.map((c) => c.usage).sort(); expect(usages).toContain("nemoclaw setup"); expect(usages).toContain("nemoclaw setup-spark"); - expect(usages).toContain("nemoclaw deploy"); expect(usages).toContain("nemoclaw start"); expect(usages).toContain("nemoclaw stop"); + expect(usages).not.toContain("nemoclaw deploy"); }); }); @@ -170,7 +174,7 @@ describe("command-registry", () => { }); describe("globalCommandTokens()", () => { - it("returns the exact set of 30 tokens matching the global dispatch commands", () => { + it("returns the exact set of 29 tokens matching the global dispatch commands", () => { const tokens = globalCommandTokens(); const expected = new Set([ "agents", @@ -182,7 +186,6 @@ describe("command-registry", () => { "list", "use", "launch", - "deploy", "setup", "setup-spark", "start",