Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
72 changes: 2 additions & 70 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));
}
}
103 changes: 99 additions & 4 deletions src/commands/simple-global-oclif-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions src/lib/diagnostics/debug-command-deps.ts
Original file line number Diff line number Diff line change
@@ -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<typeof resolveSandboxGatewayName>[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<ReadonlySet<string> | "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,
};
}
Loading
Loading