diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 16c617e6c38..83487a3ab33 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -9,7 +9,7 @@ "src/lib/adapters/openshell/client.ts": 20, "src/lib/adapters/openshell/resolve.ts": 27, "src/lib/adapters/openshell/runtime.ts": 55, - "src/lib/adapters/openshell/timeouts.ts": 39, + "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, @@ -23,12 +23,12 @@ "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": 52, + "src/lib/onboard/gateway-binding.ts": 53, "src/lib/runner.ts": 86, "src/lib/security/redact.ts": 54, "src/lib/state/onboard-session.ts": 36, "src/lib/state/mcp-lifecycle-lock.ts": 21, - "src/lib/state/registry.ts": 101, + "src/lib/state/registry.ts": 100, "src/lib/state/state-root.ts": 21, "src/lib/subprocess-env.ts": 24, "src/lib/validation.ts": 24 diff --git a/src/commands/debug.ts b/src/commands/debug.ts index ba1bfb36d61..94341d2faad 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -4,77 +4,9 @@ import { Flags } from "@oclif/core"; import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command"; -import { CLI_NAME } from "../lib/cli/branding"; -import { runDebug } from "../lib/diagnostics/debug"; import type { DebugOptions } from "../lib/diagnostics/debug"; -import type { RunDebugCommandDeps } from "../lib/diagnostics/debug-command"; import { runDebugCommandWithOptions } from "../lib/diagnostics/debug-command"; -import type { CaptureOpenshellResult } from "../lib/adapters/openshell/client"; -import { captureOpenshellCommand } from "../lib/adapters/openshell/client"; -import { OPENSHELL_PROBE_TIMEOUT_MS } from "../lib/adapters/openshell/timeouts"; -import * as registry from "../lib/state/registry"; -import { resolveOpenshell } from "../lib/adapters/openshell/resolve"; -import { parseLiveSandboxNames } from "../lib/runtime-recovery"; - -const useColor = !process.env.NO_COLOR && !!process.stderr.isTTY; -const B = useColor ? "\x1b[1m" : ""; -const R = useColor ? "\x1b[0m" : ""; -const RD = useColor ? "\x1b[1;31m" : ""; - -function captureOpenshell(rootDir: string, args: string[]): CaptureOpenshellResult { - const openshell = resolveOpenshell(); - if (!openshell) { - return { status: 1, output: "" }; - } - return captureOpenshellCommand(openshell, args, { - cwd: rootDir, - ignoreError: true, - timeout: OPENSHELL_PROBE_TIMEOUT_MS, - }); -} - -function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps { - const getDefaultSandbox = (): string | undefined => { - const { defaultSandbox, sandboxes } = registry.listSandboxes(); - if (!defaultSandbox) return undefined; - if (!sandboxes.find((sandbox) => sandbox.name === defaultSandbox)) { - console.error( - `${RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`, - ); - console.error( - ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, - ); - return undefined; - } - const liveList = captureOpenshell(rootDir, ["sandbox", "list"]); - if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(defaultSandbox)) { - console.error( - `${RD}Warning:${R} default sandbox '${defaultSandbox}' exists in the local registry but not in OpenShell.`, - ); - console.error( - ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, - ); - return undefined; - } - return defaultSandbox; - }; - - const isSandboxKnown = (name: string): boolean => { - const { sandboxes } = registry.listSandboxes(); - if (!sandboxes.find((sandbox) => sandbox.name === name)) return false; - const liveList = captureOpenshell(rootDir, ["sandbox", "list"]); - if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(name)) { - return false; - } - return true; - }; - - return { - getDefaultSandbox, - isSandboxKnown, - runDebug, - }; -} +import { buildDebugCommandDeps } from "../lib/diagnostics/debug-command-deps"; export default class DebugCliCommand extends NemoClawCommand { static id = "debug"; @@ -99,6 +31,6 @@ export default class DebugCliCommand extends NemoClawCommand { if (flags.quick) options.quick = true; if (flags.output) options.output = flags.output; if (flags.sandbox) options.sandboxName = flags.sandbox; - runDebugCommandWithOptions(options, buildDebugCommandDeps(this.config.root)); + await runDebugCommandWithOptions(options, buildDebugCommandDeps(this.config.root)); } } diff --git a/src/commands/simple-global-oclif-adapters.test.ts b/src/commands/simple-global-oclif-adapters.test.ts index faa7706375b..c70c26cb894 100644 --- a/src/commands/simple-global-oclif-adapters.test.ts +++ b/src/commands/simple-global-oclif-adapters.test.ts @@ -172,19 +172,114 @@ describe("simple global oclif adapters", testTimeoutOptions(30_000), () => { it("builds debug defaults from the sandbox registry and OpenShell liveness", async () => { mocks.listSandboxes.mockReturnValue({ defaultSandbox: "alpha", - sandboxes: [{ name: "alpha" }], + sandboxes: [{ name: "alpha", gatewayPort: 18080 }], } as never); await DebugCliCommand.run(["--quick"], rootDir); const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; - expect(deps.getDefaultSandbox()).toBe("alpha"); + await expect(deps.getDefaultSandbox()).resolves.toEqual({ + name: "alpha", + gatewayName: "nemoclaw-18080", + }); expect(mocks.captureOpenshellCommand).toHaveBeenCalledWith( "/usr/bin/openshell", - ["sandbox", "list"], - expect.objectContaining({ cwd: rootDir, ignoreError: true }), + ["sandbox", "list", "-g", "nemoclaw-18080"], + expect.objectContaining({ + cwd: rootDir, + ignoreError: true, + includeStderr: true, + includeStreams: true, + }), ); }); + it("rejects a registered debug sandbox missing from a successful OpenShell observation", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: "alpha", + sandboxes: [{ name: "alpha" }], + } as never); + mocks.captureOpenshellCommand.mockReturnValue({ status: 0, output: "beta\n" }); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getDefaultSandbox()).resolves.toBeNull(); + await expect(deps.getSandboxAvailability("alpha")).resolves.toEqual({ state: "missing" }); + }); + + it("rejects a fallback registered sandbox missing from OpenShell", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: null, + sandboxes: [{ name: "alpha" }], + } as never); + mocks.captureOpenshellCommand.mockReturnValue({ status: 0, output: "beta\n" }); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getDefaultSandbox()).resolves.toBeNull(); + }); + + it("keeps a fallback registered sandbox when OpenShell observation fails", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: null, + sandboxes: [{ name: "alpha" }], + } as never); + mocks.captureOpenshellCommand.mockReturnValue({ status: 1, output: "unavailable" }); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getDefaultSandbox()).resolves.toEqual({ name: "alpha", gatewayName: "nemoclaw" }); + }); + + it("rejects an explicit sandbox when OpenShell authentication fails", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: "alpha", + sandboxes: [{ name: "alpha" }], + } as never); + mocks.captureOpenshellCommand.mockReturnValue({ + status: 1, + output: "authentication failed", + }); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getSandboxAvailability("alpha")).resolves.toEqual({ + state: "observation_denied", + }); + }); + + it("rejects an explicit sandbox with an invalid gateway binding", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: "alpha", + sandboxes: [{ name: "alpha", gatewayName: "untrusted" }], + } as never); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getSandboxAvailability("alpha")).resolves.toEqual({ + state: "invalid_gateway", + }); + expect(mocks.captureOpenshellCommand).not.toHaveBeenCalled(); + }); + + it("keeps registered debug sandboxes when OpenShell observation fails", async () => { + mocks.listSandboxes.mockReturnValue({ + defaultSandbox: "alpha", + sandboxes: [{ name: "alpha" }], + } as never); + mocks.captureOpenshellCommand.mockReturnValue({ status: 1, output: "unavailable" }); + + await DebugCliCommand.run(["--quick"], rootDir); + + const deps = mocks.runDebugCommandWithOptions.mock.calls[0][1]; + await expect(deps.getDefaultSandbox()).resolves.toEqual({ name: "alpha", gatewayName: "nemoclaw" }); + await expect(deps.getSandboxAvailability("alpha")).resolves.toEqual({ state: "available", gatewayName: "nemoclaw" }); + }); + it("maps gateway-token flags to the gateway token action", async () => { const getSandboxAgent = vi.fn(() => "openclaw"); const fetchToken = mocks.fetchGatewayAuthTokenFromSandbox; diff --git a/src/lib/diagnostics/debug-command-deps.ts b/src/lib/diagnostics/debug-command-deps.ts new file mode 100644 index 00000000000..fbd58e6029d --- /dev/null +++ b/src/lib/diagnostics/debug-command-deps.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CLI_NAME } from "../cli/branding"; +import { runDebug } from "./debug"; +import type { RunDebugCommandDeps } from "./debug-command"; +import { captureOpenshellCommand } from "../adapters/openshell/client"; +import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { createCliOpenShellSandboxObserver } from "../adapters/openshell/sandbox-observer-cli"; +import { + namedOpenShellGateway, + selectedOpenShellGateway, + type OpenShellGatewayTarget, +} from "../adapters/openshell/sandbox-observer"; +import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; +import * as registry from "../state/registry"; + +const useColor = !process.env.NO_COLOR && !!process.stderr.isTTY; +const B = useColor ? "\x1b[1m" : ""; +const R = useColor ? "\x1b[0m" : ""; +const RD = useColor ? "\x1b[1;31m" : ""; + +function resolveDebugGatewayName( + sandbox: Parameters[0], +): string | null { + try { + return resolveSandboxGatewayName(sandbox); + } catch { + return null; + } +} + +export function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps { + const sandboxObserver = createCliOpenShellSandboxObserver({ + capture: (args, options) => { + const openshell = resolveOpenshell(); + if (!openshell) return { status: 1, output: "" }; + return captureOpenshellCommand(openshell, args, { cwd: rootDir, ...options }); + }, + }); + + const liveSandboxNames = async ( + target: OpenShellGatewayTarget, + ): Promise | "denied" | undefined> => { + const result = await sandboxObserver.listSandboxes({ target }); + if (!result.ok) { + const denied = + result.error.kind === "authentication" || + (result.error.kind === "transport" && result.error.reason === "identity_mismatch"); + return denied ? "denied" : undefined; + } + return new Set(result.value.sandboxes.map((sandbox) => sandbox.name)); + }; + + const getSandboxAvailability: RunDebugCommandDeps["getSandboxAvailability"] = async (name) => { + const { sandboxes } = registry.listSandboxes(); + const registered = sandboxes.find((sandbox) => sandbox.name === name); + if (!registered) return { state: "unregistered" }; + const gatewayName = resolveDebugGatewayName(registered); + if (!gatewayName) return { state: "invalid_gateway" }; + const liveNames = await liveSandboxNames(namedOpenShellGateway(gatewayName)); + if (liveNames === "denied") return { state: "observation_denied" }; + return !liveNames || liveNames.has(name) + ? { state: "available", gatewayName } + : { state: "missing" }; + }; + + const getDefaultSandbox: RunDebugCommandDeps["getDefaultSandbox"] = async () => { + const { defaultSandbox, sandboxes } = registry.listSandboxes(); + const selectedName = defaultSandbox ?? sandboxes.find((sandbox) => sandbox.name)?.name; + if (!selectedName) { + const liveNames = await liveSandboxNames(selectedOpenShellGateway()); + if (liveNames === "denied") { + console.error(`${RD}Warning:${R} OpenShell rejected the sandbox observation.`); + console.error(" Verify OpenShell authentication and gateway identity, then retry.\n"); + return null; + } + return { name: liveNames?.values().next().value ?? "default" }; + } + + const availability = await getSandboxAvailability(selectedName); + if (availability.state === "available") { + return { name: selectedName, gatewayName: availability.gatewayName }; + } + + const label = defaultSandbox ? "default sandbox" : "sandbox"; + if (availability.state === "unregistered") { + console.error(`${RD}Warning:${R} ${label} '${selectedName}' is no longer in the registry.`); + console.error( + ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, + ); + } else if (availability.state === "invalid_gateway") { + console.error( + `${RD}Warning:${R} ${label} '${selectedName}' has an invalid registered gateway binding.`, + ); + console.error( + " Restore gatewayName and gatewayPort from a trusted backup. Otherwise, back up and remove the sandbox before onboarding it again. Do not copy a gateway binding from another sandbox.\n", + ); + } else if (availability.state === "observation_denied") { + console.error( + `${RD}Warning:${R} OpenShell rejected observation of sandbox '${selectedName}'.`, + ); + console.error(" Verify OpenShell authentication and gateway identity, then retry.\n"); + } else { + console.error( + `${RD}Warning:${R} ${label} '${selectedName}' exists in the local registry but not in OpenShell.`, + ); + console.error( + ` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`, + ); + } + return null; + }; + + return { + getDefaultSandbox, + getSandboxAvailability, + runDebug, + }; +} diff --git a/src/lib/diagnostics/debug-command.test.ts b/src/lib/diagnostics/debug-command.test.ts index e46c88738f6..45cc1efd9cc 100644 --- a/src/lib/diagnostics/debug-command.test.ts +++ b/src/lib/diagnostics/debug-command.test.ts @@ -6,13 +6,13 @@ import { describe, expect, it, vi } from "vitest"; import { runDebugCommandWithOptions } from "./debug-command"; describe("debug command", () => { - it("runs parsed debug options and falls back to the default sandbox", () => { + it("runs parsed debug options and falls back to the default sandbox", async () => { const runDebug = vi.fn(); - runDebugCommandWithOptions( + await runDebugCommandWithOptions( { quick: true, output: "/tmp/out.tgz" }, { - getDefaultSandbox: () => "alpha", - isSandboxKnown: () => true, + getDefaultSandbox: async () => ({ name: "alpha", gatewayName: "nemoclaw" }), + getSandboxAvailability: async () => ({ state: "available", gatewayName: "nemoclaw" }), runDebug, }, ); @@ -20,42 +20,43 @@ describe("debug command", () => { quick: true, output: "/tmp/out.tgz", sandboxName: "alpha", + gatewayName: "nemoclaw", }); }); - it("accepts an explicit --sandbox name that is registered", () => { + it("accepts an explicit --sandbox name that is registered", async () => { const runDebug = vi.fn(); - const isSandboxKnown = vi.fn().mockReturnValue(true); - runDebugCommandWithOptions( + const getSandboxAvailability = vi.fn().mockResolvedValue({ state: "available", gatewayName: "nemoclaw" }); + await runDebugCommandWithOptions( { sandboxName: "alpha" }, { - getDefaultSandbox: () => undefined, - isSandboxKnown, + getDefaultSandbox: async () => ({ name: "default", gatewayName: "nemoclaw" }), + getSandboxAvailability, runDebug, }, ); - expect(isSandboxKnown).toHaveBeenCalledWith("alpha"); - expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" }); + expect(getSandboxAvailability).toHaveBeenCalledWith("alpha"); + expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha", gatewayName: "nemoclaw" }); }); - it("rejects an explicit --sandbox name that is not registered, exits non-zero, skips runDebug", () => { + it("rejects an explicit --sandbox name that is not registered, exits non-zero, skips runDebug", async () => { const runDebug = vi.fn(); const errorLines: string[] = []; const exit = vi.fn(() => { throw new Error("exit"); }) as unknown as (code: number) => never; - expect(() => + await expect( runDebugCommandWithOptions( { sandboxName: "does-not-exist", output: "/tmp/out.tgz" }, { - getDefaultSandbox: () => "alpha", - isSandboxKnown: () => false, + getDefaultSandbox: async () => ({ name: "alpha", gatewayName: "nemoclaw" }), + getSandboxAvailability: async () => ({ state: "unregistered" }), runDebug, errorLine: (msg) => errorLines.push(msg), exit, }, ), - ).toThrow("exit"); + ).rejects.toThrow("exit"); expect(exit).toHaveBeenCalledWith(1); expect(runDebug).not.toHaveBeenCalled(); expect(errorLines[0]).toContain("does-not-exist"); @@ -63,35 +64,86 @@ describe("debug command", () => { expect(errorLines.join("\n")).toContain("nemoclaw list"); }); - it("validates an env-sourced sandbox name and reports the env source on failure", () => { + it("reports safe recovery for an invalid sandbox gateway binding", async () => { + const runDebug = vi.fn(); + const errorLine = vi.fn(); + const exit = vi.fn(() => { + throw new Error("exit"); + }) as unknown as (code: number) => never; + + await expect( + runDebugCommandWithOptions( + { sandboxName: "alpha" }, + { + getDefaultSandbox: async () => ({ name: "default", gatewayName: "nemoclaw" }), + getSandboxAvailability: async () => ({ state: "invalid_gateway" }), + runDebug, + errorLine, + exit, + }, + ), + ).rejects.toThrow("exit"); + + expect(errorLine).toHaveBeenCalledWith( + " Restore gatewayName and gatewayPort from a trusted backup. Otherwise, back up and remove the sandbox before onboarding it again. Do not copy a gateway binding from another sandbox.", + ); + expect(runDebug).not.toHaveBeenCalled(); + }); + + it("identifies an explicit registered sandbox missing from OpenShell", async () => { const runDebug = vi.fn(); const errorLines: string[] = []; const exit = vi.fn(() => { throw new Error("exit"); }) as unknown as (code: number) => never; - expect(() => + + await expect( + runDebugCommandWithOptions( + { sandboxName: "alpha" }, + { + getDefaultSandbox: async () => ({ name: "alpha", gatewayName: "nemoclaw" }), + getSandboxAvailability: async () => ({ state: "missing" }), + runDebug, + errorLine: (msg) => errorLines.push(msg), + exit, + }, + ), + ).rejects.toThrow("exit"); + + expect(runDebug).not.toHaveBeenCalled(); + expect(errorLines.join("\n")).toContain("local registry but not in OpenShell"); + expect(errorLines.join("\n")).toContain("nemoclaw onboard"); + }); + + it("validates an env-sourced sandbox name and reports the env source on failure", async () => { + const runDebug = vi.fn(); + const errorLines: string[] = []; + const exit = vi.fn(() => { + throw new Error("exit"); + }) as unknown as (code: number) => never; + await expect( runDebugCommandWithOptions( {}, { env: { NEMOCLAW_SANDBOX_NAME: "ghost" } as NodeJS.ProcessEnv, - getDefaultSandbox: () => "alpha", - isSandboxKnown: () => false, + getDefaultSandbox: async () => ({ name: "alpha", gatewayName: "nemoclaw" }), + getSandboxAvailability: async () => ({ state: "unregistered" }), runDebug, errorLine: (msg) => errorLines.push(msg), exit, }, ), - ).toThrow("exit"); + ).rejects.toThrow("exit"); expect(exit).toHaveBeenCalledWith(1); expect(runDebug).not.toHaveBeenCalled(); expect(errorLines[0]).toContain("ghost"); expect(errorLines[0]).toContain("NEMOCLAW_SANDBOX_NAME"); }); - it("prefers NEMOCLAW_SANDBOX_NAME over NEMOCLAW_SANDBOX and SANDBOX_NAME", () => { + it("prefers NEMOCLAW_SANDBOX_NAME over NEMOCLAW_SANDBOX and SANDBOX_NAME", async () => { const runDebug = vi.fn(); - const isSandboxKnown = vi.fn().mockReturnValue(true); - runDebugCommandWithOptions( + const getSandboxAvailability = vi.fn().mockResolvedValue({ state: "available", gatewayName: "nemoclaw" }); + await runDebugCommandWithOptions( {}, { env: { @@ -99,45 +151,69 @@ describe("debug command", () => { NEMOCLAW_SANDBOX: "secondary", SANDBOX_NAME: "tertiary", } as NodeJS.ProcessEnv, - getDefaultSandbox: () => undefined, - isSandboxKnown, + getDefaultSandbox: async () => ({ name: "default", gatewayName: "nemoclaw" }), + getSandboxAvailability, runDebug, }, ); - expect(isSandboxKnown).toHaveBeenCalledWith("primary"); - expect(runDebug).toHaveBeenCalledWith({ sandboxName: "primary" }); + expect(getSandboxAvailability).toHaveBeenCalledWith("primary"); + expect(runDebug).toHaveBeenCalledWith({ sandboxName: "primary", gatewayName: "nemoclaw" }); }); - it("flag overrides env vars when both are present", () => { + it("flag overrides env vars when both are present", async () => { const runDebug = vi.fn(); - const isSandboxKnown = vi.fn().mockReturnValue(true); - runDebugCommandWithOptions( + const getSandboxAvailability = vi.fn().mockResolvedValue({ state: "available", gatewayName: "nemoclaw" }); + await runDebugCommandWithOptions( { sandboxName: "alpha" }, { env: { NEMOCLAW_SANDBOX: "beta" } as NodeJS.ProcessEnv, - getDefaultSandbox: () => undefined, - isSandboxKnown, + getDefaultSandbox: async () => ({ name: "default", gatewayName: "nemoclaw" }), + getSandboxAvailability, runDebug, }, ); - expect(isSandboxKnown).toHaveBeenCalledWith("alpha"); - expect(isSandboxKnown).not.toHaveBeenCalledWith("beta"); - expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" }); + expect(getSandboxAvailability).toHaveBeenCalledWith("alpha"); + expect(getSandboxAvailability).not.toHaveBeenCalledWith("beta"); + expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha", gatewayName: "nemoclaw" }); + }); + + it("stops before diagnostics when the configured default sandbox is rejected", async () => { + const runDebug = vi.fn(); + + const exit = vi.fn(() => { + throw new Error("exit"); + }) as unknown as (code: number) => never; + + await expect( + runDebugCommandWithOptions( + {}, + { + env: {} as NodeJS.ProcessEnv, + getDefaultSandbox: async () => null, + getSandboxAvailability: async () => ({ state: "available", gatewayName: "nemoclaw" }), + runDebug, + exit, + }, + ), + ).rejects.toThrow("exit"); + + expect(exit).toHaveBeenCalledWith(1); + expect(runDebug).not.toHaveBeenCalled(); }); - it("falls back to getDefaultSandbox when neither flag nor env is set", () => { + it("falls back to getDefaultSandbox when neither flag nor env is set", async () => { const runDebug = vi.fn(); - const isSandboxKnown = vi.fn(); - runDebugCommandWithOptions( + const getSandboxAvailability = vi.fn(); + await runDebugCommandWithOptions( {}, { env: {} as NodeJS.ProcessEnv, - getDefaultSandbox: () => "alpha", - isSandboxKnown, + getDefaultSandbox: async () => ({ name: "alpha", gatewayName: "nemoclaw" }), + getSandboxAvailability, runDebug, }, ); - expect(isSandboxKnown).not.toHaveBeenCalled(); - expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" }); + expect(getSandboxAvailability).not.toHaveBeenCalled(); + expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha", gatewayName: "nemoclaw" }); }); }); diff --git a/src/lib/diagnostics/debug-command.ts b/src/lib/diagnostics/debug-command.ts index 841efb00f34..a5c63eee5e1 100644 --- a/src/lib/diagnostics/debug-command.ts +++ b/src/lib/diagnostics/debug-command.ts @@ -3,9 +3,14 @@ import type { DebugOptions } from "./debug"; +export type DebugSandboxSelection = Readonly<{ name: string; gatewayName?: string }>; +export type DebugSandboxAvailability = + | Readonly<{ state: "available"; gatewayName: string }> + | Readonly<{ state: "unregistered" | "missing" | "invalid_gateway" | "observation_denied" }>; + export interface RunDebugCommandDeps { - getDefaultSandbox: () => string | undefined; - isSandboxKnown: (name: string) => boolean; + getDefaultSandbox: () => Promise; + getSandboxAvailability: (name: string) => Promise; runDebug: (options: DebugOptions) => void; env?: NodeJS.ProcessEnv; errorLine?: (message: string) => void; @@ -31,7 +36,10 @@ function resolveExplicitName( return null; } -export function runDebugCommandWithOptions(options: DebugOptions, deps: RunDebugCommandDeps): void { +export async function runDebugCommandWithOptions( + options: DebugOptions, + deps: RunDebugCommandDeps, +): Promise { const opts = { ...options }; const env = deps.env ?? process.env; const errorLine = deps.errorLine ?? ((msg: string) => console.error(msg)); @@ -43,17 +51,40 @@ export function runDebugCommandWithOptions(options: DebugOptions, deps: RunDebug const explicit = resolveExplicitName(opts, env); if (explicit) { - if (!deps.isSandboxKnown(explicit.name)) { + const availability = await deps.getSandboxAvailability(explicit.name); + if (availability.state !== "available") { const sourceLabel = explicit.source === "env" && explicit.envVar ? ` (from ${explicit.envVar})` : ""; - errorLine(`Error: Sandbox '${explicit.name}'${sourceLabel} is not registered.`); - errorLine(" Run `nemoclaw list` to see available sandboxes."); + if (availability.state === "unregistered") { + errorLine(`Error: Sandbox '${explicit.name}'${sourceLabel} is not registered.`); + errorLine(" Run `nemoclaw list` to see available sandboxes."); + } else if (availability.state === "observation_denied") { + errorLine( + `Error: OpenShell rejected observation of sandbox '${explicit.name}'${sourceLabel}.`, + ); + errorLine(" Verify OpenShell authentication and gateway identity, then retry."); + } else if (availability.state === "invalid_gateway") { + errorLine(`Error: Sandbox '${explicit.name}'${sourceLabel} has an invalid registered gateway binding.`); + errorLine( + " Restore gatewayName and gatewayPort from a trusted backup. Otherwise, back up and remove the sandbox before onboarding it again. Do not copy a gateway binding from another sandbox.", + ); + } else { + errorLine(`Error: Sandbox '${explicit.name}'${sourceLabel} exists in the local registry but not in OpenShell.`); + errorLine(" Run `nemoclaw onboard` again to recreate or select a sandbox."); + } exit(1); return; } opts.sandboxName = explicit.name; + opts.gatewayName = availability.gatewayName; } else { - opts.sandboxName = deps.getDefaultSandbox(); + const defaultSandbox = await deps.getDefaultSandbox(); + if (defaultSandbox === null) { + exit(1); + return; + } + opts.sandboxName = defaultSandbox.name; + opts.gatewayName = defaultSandbox.gatewayName; } deps.runDebug(opts); diff --git a/src/lib/diagnostics/debug.ts b/src/lib/diagnostics/debug.ts index 659db826f79..7c21fe0c1a7 100644 --- a/src/lib/diagnostics/debug.ts +++ b/src/lib/diagnostics/debug.ts @@ -7,9 +7,8 @@ import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { dockerExecFileSync } from "../adapters/docker/exec"; -import { openshellSandboxSshHost } from "../adapters/openshell/sandbox-ssh-host"; +import { resolveOpenshellSandboxSshHost } from "../adapters/openshell/sandbox-ssh-host"; import { DASHBOARD_PORT } from "../core/ports"; -import { listSandboxes } from "../state/registry"; import { createTarball as createDiagnosticsTarball } from "./tarball"; // --------------------------------------------------------------------------- @@ -19,6 +18,8 @@ import { createTarball as createDiagnosticsTarball } from "./tarball"; export interface DebugOptions { /** Target sandbox name (auto-detected if omitted). */ sandboxName?: string; + /** OpenShell gateway that owns the selected registered sandbox. */ + gatewayName?: string; /** Only collect minimal diagnostics. */ quick?: boolean; /** Write a tarball to this path. */ @@ -232,45 +233,6 @@ function collectDmesg(collectDir: string, opts: DebugOptions = {}): void { } } -// --------------------------------------------------------------------------- -// Auto-detect sandbox name -// --------------------------------------------------------------------------- - -function detectSandboxName(): string { - // First, check the local registry for the default sandbox. This is - // the authoritative source — it reflects the user's actual onboard - // choices and survives gateway restarts. Falling back to "default" - // without checking the registry was the bug in #1728: debug always - // targeted a sandbox named "default" even though the user's sandbox - // was named something else (e.g. "my-assistant"). - try { - const registry = listSandboxes(); - if (registry.defaultSandbox) return registry.defaultSandbox; - const names = registry.sandboxes.map((s) => s.name).filter(Boolean); - if (names.length > 0) return names[0]; - } catch { - /* registry unreadable — fall through to openshell probe */ - } - - // Fallback: ask the live gateway directly - if (!commandExists("openshell")) return "default"; - try { - const output = execFileSync("openshell", ["sandbox", "list"], { - encoding: "utf-8", - timeout: 10_000, - stdio: ["ignore", "pipe", "ignore"], - }); - const lines = output.split("\n").filter((l) => l.trim().length > 0); - for (const line of lines) { - const first = line.trim().split(/\s+/)[0]; - if (first && first.toLowerCase() !== "name") return first; - } - } catch { - /* ignore */ - } - return "default"; -} - // --------------------------------------------------------------------------- // Diagnostic sections // --------------------------------------------------------------------------- @@ -362,51 +324,70 @@ function collectDocker(collectDir: string, quick: boolean): void { } } -function collectOpenshell(collectDir: string, sandboxName: string, quick: boolean): void { +function collectOpenshell( + collectDir: string, + sandboxName: string, + gatewayName: string | undefined, + quick: boolean, +): void { + const gatewayArgs = gatewayName ? ["-g", gatewayName] : []; section("OpenShell"); - collect(collectDir, "openshell-status", "openshell", ["status"]); - collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); - collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); - collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); + collect(collectDir, "openshell-status", "openshell", ["status", ...gatewayArgs]); + collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list", ...gatewayArgs]); + collect(collectDir, "openshell-sandbox-get", "openshell", [ + "sandbox", + "get", + ...gatewayArgs, + sandboxName, + ]); + collect(collectDir, "openshell-logs", "openshell", ["logs", ...gatewayArgs, sandboxName]); if (!quick) { - collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); + collect(collectDir, "openshell-gateway-info", "openshell", [ + "gateway", + "info", + ...gatewayArgs, + ]); } } -function collectSandboxInternals(collectDir: string, sandboxName: string, quick: boolean): void { +function collectSandboxInternals( + collectDir: string, + sandboxName: string, + gatewayName: string | undefined, + quick: boolean, +): void { if (!commandExists("openshell")) return; - // Check if sandbox exists. OpenShell ssh-config may succeed for unknown - // names, so verify the live sandbox first. - try { - execFileSync("openshell", ["sandbox", "get", sandboxName], { - encoding: "utf-8", - timeout: 10_000, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - return; - } - section("Sandbox Internals"); // Generate temporary SSH config in a private directory. const sshConfigDir = mkdtempSync(join(tmpdir(), "nemoclaw-ssh-")); const sshConfigPath = join(sshConfigDir, "config"); try { - const sshResult = spawnSync("openshell", ["sandbox", "ssh-config", sandboxName], { + const gatewayArgs = gatewayName ? ["-g", gatewayName] : []; + const sshResult = spawnSync( + "openshell", + ["sandbox", "ssh-config", ...gatewayArgs, sandboxName], + { timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"], - encoding: "utf-8", - }); + encoding: "utf-8", + }, + ); if (sshResult.status !== 0) { warn(`Could not generate SSH config for sandbox '${sandboxName}', skipping internals`); return; } - writeFileSync(sshConfigPath, sshResult.stdout ?? ""); - - const sshHost = openshellSandboxSshHost(sandboxName); + const sshConfig = sshResult.stdout ?? ""; + const sshHost = resolveOpenshellSandboxSshHost(sandboxName, sshConfig); + if (!sshHost) { + warn( + `SSH config did not declare sandbox '${sandboxName}', skipping internals`, + ); + return; + } + writeFileSync(sshConfigPath, sshConfig); const sshBase = [ "-F", sshConfigPath, @@ -539,13 +520,10 @@ export function runDebug(opts: DebugOptions = {}): void { // Resolve sandbox name. The CLI wrapper (runDebugCommandWithOptions) is the // sole supported caller; it already trims, validates, and applies the - // documented precedence (--sandbox > NEMOCLAW_SANDBOX_NAME > NEMOCLAW_SANDBOX + // command precedence (--sandbox > NEMOCLAW_SANDBOX_NAME > NEMOCLAW_SANDBOX // > SANDBOX_NAME) before calling here. Reading env again would let // whitespace-only values bypass validation, so only trim the option. - let sandboxName = opts.sandboxName?.trim() ?? ""; - if (!sandboxName) { - sandboxName = detectSandboxName(); - } + const sandboxName = opts.sandboxName?.trim() || "default"; // Create temp collection directory const collectDir = mkdtempSync(join(tmpdir(), "nemoclaw-debug-")); @@ -560,9 +538,9 @@ export function runDebug(opts: DebugOptions = {}): void { collectProcesses(collectDir, quick); collectGpu(collectDir, quick); collectDocker(collectDir, quick); - collectOpenshell(collectDir, sandboxName, quick); + collectOpenshell(collectDir, sandboxName, opts.gatewayName, quick); collectOnboardSession(collectDir, repoDir); - collectSandboxInternals(collectDir, sandboxName, quick); + collectSandboxInternals(collectDir, sandboxName, opts.gatewayName, quick); if (!quick) { collectNetwork(collectDir); diff --git a/test/cli/debug-command.test.ts b/test/cli/debug-command.test.ts index 9e2a6991889..fb05c73b411 100644 --- a/test/cli/debug-command.test.ts +++ b/test/cli/debug-command.test.ts @@ -121,6 +121,25 @@ describe("CLI debug command", () => { }, ); + it( + "debug scopes OpenShell commands to the registered non-default gateway", + testTimeoutOptions(30_000), + ({ resources }) => { + const argsLog = path.join(resources.home("nemoclaw-cli-debug-gateway-log-").home, "openshell-args.log"); + const env = createDebugCommandTestEnv(resources, "nemoclaw-cli-debug-gateway-", { + gatewayPort: 18080, + openshellArgsLog: argsLog, + }); + + const r = runWithEnv("debug --quick", env, 30000); + + expect(r.code).toBe(0); + const invocations = fs.readFileSync(argsLog, "utf-8"); + expect(invocations).toContain("sandbox list -g nemoclaw-18080"); + expect(invocations).toContain("sandbox ssh-config -g nemoclaw-18080"); + }, + ); + it("debug --sandbox NAME rejects an unregistered name and exits non-zero", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-unknown-")); writeSandboxRegistry(home); @@ -171,7 +190,7 @@ describe("CLI debug command", () => { ); expect(r.code).not.toBe(0); expect(r.out).toContain("stale-box"); - expect(r.out).toContain("not registered"); + expect(r.out).toContain("local registry but not in OpenShell"); expect(fs.existsSync(tarball)).toBe(false); }, ); @@ -191,7 +210,7 @@ describe("CLI debug command", () => { { mode: 0o600 }, ); const r = runWithEnv("debug --quick 2>&1", { HOME: home }, 30000); - expect(r.code).toBe(0); + expect(r.code).not.toBe(0); expect(r.out).toContain("Warning"); expect(r.out).toContain("ghost"); expect(r.out).toContain("--sandbox NAME"); diff --git a/test/cli/helpers.ts b/test/cli/helpers.ts index 1a5e088a82f..4b6a07d749e 100644 --- a/test/cli/helpers.ts +++ b/test/cli/helpers.ts @@ -508,14 +508,14 @@ export function createCloudflaredServiceDir(prefix: string): { export function createDebugCommandTestEnv( resources: OwnedTestResources, prefix: string, - options: { extraSandboxNames?: string[] } = {}, + options: { extraSandboxNames?: string[]; gatewayPort?: number; openshellArgsLog?: string } = {}, ): Record { const { home, bin: localBin } = resources.home(prefix); const sandboxName = `${prefix}${process.pid.toString(36)}-${Date.now().toString(36)}`; fs.mkdirSync(localBin, { recursive: true }); // Register the env-sourced sandbox plus any extra names supplied via the // --sandbox flag so the validation gate accepts them. - writeSandboxRegistry(home, sandboxName); + writeSandboxRegistry(home, sandboxName, options.gatewayPort ? { gatewayPort: options.gatewayPort } : {}); if (options.extraSandboxNames && options.extraSandboxNames.length > 0) { const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); const current = JSON.parse(fs.readFileSync(registryPath, "utf-8")) as { @@ -539,6 +539,9 @@ export function createDebugCommandTestEnv( path.join(localBin, "openshell"), [ "#!/bin/sh", + ...(options.openshellArgsLog + ? [`printf '%s\n' "$*" >> ${JSON.stringify(options.openshellArgsLog)}`] + : []), 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', ...listLines.map((line) => ` echo ${JSON.stringify(line)}`), " exit 0",