Skip to content
Closed
34 changes: 34 additions & 0 deletions src/commands/sandbox/oclif-command-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ const mocks = vi.hoisted(() => {
shieldsStatus: vi.fn(),
shieldsUp: vi.fn(),
showSandboxStatus: vi.fn().mockResolvedValue(undefined),
buildStatusCommandDeps: vi.fn(() => ({ statusDeps: true })),
getSandboxStatusReport: vi.fn(() => ({
schemaVersion: 1,
sandbox: { name: "alpha" },
gatewayHealth: { healthy: true, state: "healthy_named" },
services: [],
})),
SandboxConfigError,
};
});
Expand All @@ -48,6 +55,14 @@ vi.mock("../../lib/actions/sandbox/status", () => ({
showSandboxStatus: mocks.showSandboxStatus,
}));

vi.mock("../../lib/inventory", () => ({
getSandboxStatusReport: mocks.getSandboxStatusReport,
}));

vi.mock("../../lib/status-command-deps", () => ({
buildStatusCommandDeps: mocks.buildStatusCommandDeps,
}));

vi.mock("../../lib/actions/sandbox/policy-channel", () => ({
listSandboxChannels: mocks.listSandboxChannels,
listSandboxPolicies: mocks.listSandboxPolicies,
Expand Down Expand Up @@ -149,6 +164,25 @@ describe("sandbox oclif command adapters", () => {
expect(mocks.configGet).toHaveBeenCalledWith("alpha", { key: "model", format: "yaml" });
});

it("maps sandbox status --json to a structured sandbox report", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await SandboxStatusCommand.run(["alpha", "--json"], rootDir);

expect(mocks.buildStatusCommandDeps).toHaveBeenCalledWith(rootDir);
expect(mocks.getSandboxStatusReport).toHaveBeenCalledWith({ statusDeps: true }, "alpha");
expect(mocks.showSandboxStatus).not.toHaveBeenCalled();
expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toEqual({
schemaVersion: 1,
sandbox: { name: "alpha" },
gatewayHealth: { healthy: true, state: "healthy_named" },
services: [],
});
} finally {
log.mockRestore();
}
});

it("maps config action failures to oclif exit codes", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const previousExitCode = process.exitCode;
Expand Down
22 changes: 19 additions & 3 deletions src/commands/sandbox/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,39 @@
import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";

import { showSandboxStatus } from "../../lib/actions/sandbox/status";
import { getSandboxStatusReport } from "../../lib/inventory";
import { sandboxNameArg } from "../../lib/sandbox/command-support";
import { buildStatusCommandDeps } from "../../lib/status-command-deps";

/**
* CLI command to query and show the detailed health and status of a sandbox.
*/
export default class SandboxStatusCommand extends NemoClawCommand {
static id = "sandbox:status";
static strict = true;
static enableJsonFlag = true;
static summary = "Sandbox health and NIM status";
static description = "Show sandbox health, OpenShell gateway state, and local NIM status.";
static usage = ["<name>"];
static examples = ["<%= config.bin %> sandbox status alpha"];
static usage = ["<name> [--json]"];
static examples = ["<%= config.bin %> sandbox status alpha", "<%= config.bin %> sandbox status alpha --json"];
static args = {
sandboxName: sandboxNameArg,
};
static flags = {
};

public async run(): Promise<void> {
/**
* Run the sandbox status command. Supports standard printed output or JSON format.
*/
public async run(): Promise<unknown> {
const { args } = await this.parse(SandboxStatusCommand);
if (this.jsonEnabled()) {
const report = getSandboxStatusReport(buildStatusCommandDeps(this.config.root), args.sandboxName);
if (!report.sandbox || (report.gatewayHealth && !report.gatewayHealth.healthy)) {
process.exitCode = 1;
}
return report;
}
await showSandboxStatus(args.sandboxName);
}
}
11 changes: 10 additions & 1 deletion src/commands/simple-global-oclif-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => {
runStartCommand: vi.fn().mockResolvedValue(undefined),
runStopCommand: vi.fn(),
runUninstallCommand: vi.fn(),
showTunnelStatus: vi.fn(),
showRootHelp: vi.fn(),
showVersion: vi.fn(),
spawnSync: vi.fn(),
Expand Down Expand Up @@ -72,8 +73,13 @@ vi.mock("../lib/actions/global", () => ({
vi.mock("../lib/adapters/openshell/client", () => ({ captureOpenshellCommand: mocks.captureOpenshellCommand }));
vi.mock("../lib/state/registry", () => ({ listSandboxes: mocks.listSandboxes }));
vi.mock("../lib/adapters/openshell/resolve", () => ({ resolveOpenshell: mocks.resolveOpenshell }));
vi.mock("../lib/tunnel/services", () => ({ startAll: mocks.startAll, stopAll: mocks.stopAll }));
vi.mock("../lib/tunnel/services", () => ({
showStatus: mocks.showTunnelStatus,
startAll: mocks.startAll,
stopAll: mocks.stopAll,
}));
vi.mock("../lib/tunnel/service-command", () => ({
resolveDefaultSandboxName: vi.fn(() => undefined),
runStartCommand: mocks.runStartCommand,
runStopCommand: mocks.runStopCommand,
}));
Expand All @@ -92,6 +98,7 @@ import DeprecatedStopCommand from "./stop";
import RootHelpCommand from "./root/help";
import VersionCommand from "./root/version";
import TunnelStartCommand from "./tunnel/start";
import TunnelStatusCommand from "./tunnel/status";
import TunnelStopCommand from "./tunnel/stop";
import UninstallCliCommand from "./uninstall";

Expand Down Expand Up @@ -216,11 +223,13 @@ describe("simple global oclif adapters", () => {

it("maps tunnel and deprecated service commands to service actions", async () => {
await TunnelStartCommand.run([], rootDir);
await TunnelStatusCommand.run([], rootDir);
await TunnelStopCommand.run([], rootDir);
await DeprecatedStartCommand.run([], rootDir);
await DeprecatedStopCommand.run([], rootDir);

expect(mocks.runStartCommand).toHaveBeenCalledTimes(2);
expect(mocks.showTunnelStatus).toHaveBeenCalledWith({ sandboxName: undefined });
expect(mocks.runStopCommand).toHaveBeenCalledTimes(2);
expect(mocks.runStartCommand).toHaveBeenCalledWith(
expect.objectContaining({ listSandboxes: expect.any(Function), startAll: mocks.startAll }),
Expand Down
38 changes: 38 additions & 0 deletions src/commands/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";
import { CLI_NAME } from "../lib/cli/branding";

/**
* Root CLI command for cloudflared public-URL tunnel management.
*/
export default class TunnelCommand extends NemoClawCommand {
static id = "tunnel";
static strict = true;
static summary = "Manage the cloudflared public-URL tunnel";
static description = "Start, inspect, or stop the cloudflared public-URL tunnel.";
static usage = ["tunnel <start|status|stop>"];
static examples = [
"<%= config.bin %> tunnel start",
"<%= config.bin %> tunnel status",
"<%= config.bin %> tunnel stop",
];
static flags = {
};

/**
* Run the root tunnel command to display help text and list subcommands.
*/
public async run(): Promise<void> {
await this.parse(TunnelCommand);
this.log("");
this.log(` Usage: ${CLI_NAME} tunnel <subcommand>`);
this.log("");
this.log(" Subcommands:");
this.log(" start Start the cloudflared public-URL tunnel");
this.log(" status Show cloudflared tunnel process and public URL status");
this.log(" stop Stop the cloudflared public-URL tunnel");
this.log("");
}
}
31 changes: 31 additions & 0 deletions src/commands/tunnel/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command";

import { showStatus } from "../../lib/tunnel/services";
import { resolveDefaultSandboxName } from "../../lib/tunnel/service-command";
import { serviceDeps } from "../../lib/tunnel/command-support";

/**
* CLI command to show the status of the cloudflared public-URL tunnel.
*/
export default class TunnelStatusCommand extends NemoClawCommand {
static id = "tunnel:status";
static strict = true;
static summary = "Show cloudflared tunnel status";
static description = "Show cloudflared tunnel process and public URL status.";
static usage = ["tunnel status"];
static examples = ["<%= config.bin %> tunnel status"];
static flags = {
};

/**
* Run the tunnel status command to display tunnel and public URL health.
*/
public async run(): Promise<void> {
await this.parse(TunnelStatusCommand);
const deps = serviceDeps();
showStatus({ sandboxName: resolveDefaultSandboxName(deps.listSandboxes) });
}
}
7 changes: 7 additions & 0 deletions src/lib/actions/sandbox/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
createSystemDeps as createSessionDeps,
getActiveSandboxSessions,
} from "../../state/sandbox-session";
import { dockerInfo } from "../../adapters/docker/info";
import { getSandboxDockerHealth } from "./docker-health";
import { classifyGatewayFailure, getLayerHeader } from "./gateway-failure-classifier";
import type { SandboxGatewayState } from "./gateway-state";
Expand Down Expand Up @@ -253,6 +254,12 @@ export async function showSandboxStatus(sandboxName: string): Promise<void> {
}

if (lookup.state === "present") {
if (dockerInfo({ ignoreError: true, timeout: 3000 }).length === 0) {
console.log("");
await printGatewayFailureLayerHeader(sandboxName);
process.exitCode = 1;
return;
}
console.log("");
if ("recoveredGateway" in lookup && lookup.recoveredGateway) {
console.log(
Expand Down
29 changes: 24 additions & 5 deletions src/lib/adapters/openshell/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { execSync } from "node:child_process";
import { accessSync, constants } from "node:fs";
import path from "node:path";

export interface ResolveOpenshellOptions {
/** Mock result for `command -v` (undefined = run real command). */
Expand All @@ -13,6 +14,21 @@ export interface ResolveOpenshellOptions {
home?: string;
}

/**
* Check if the given value is an absolute path.
* Supports both Windows-style absolute paths (with drive letters) and POSIX-style paths (starting with '/').
*/
function isAbsolutePath(value: string): boolean {
return path.isAbsolute(value) || value.startsWith("/");
}

/**
* Return the expected local openshell executable path located under the user's home directory.
*/
function homeLocalOpenshell(home: string): string {
return path.join(home, ".local", "bin", "openshell");
}

/**
* Resolve the openshell binary path.
*
Expand All @@ -33,25 +49,28 @@ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | n
});

const override = process.env.NEMOCLAW_OPENSHELL_BIN;
if (override?.startsWith("/") && checkExecutable(override)) {
if (override && isAbsolutePath(override) && checkExecutable(override)) {
return override;
}

// Step 1: command -v
if (opts.commandVResult === undefined) {
try {
const found = execSync("command -v openshell", { encoding: "utf-8" }).trim();
if (found.startsWith("/")) return found;
const found = execSync("command -v openshell", {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (isAbsolutePath(found)) return found;
} catch {
/* ignored */
}
} else if (opts.commandVResult?.startsWith("/")) {
} else if (opts.commandVResult && isAbsolutePath(opts.commandVResult)) {
return opts.commandVResult;
}

// Step 2: fallback candidates
const candidates = [
...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []),
...(home && isAbsolutePath(home) ? [homeLocalOpenshell(home)] : []),
"/usr/local/bin/openshell",
"/usr/bin/openshell",
];
Expand Down
6 changes: 6 additions & 0 deletions src/lib/cli/public-display-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
"order": 32
}
],
"tunnel:status": [
{
"group": "Services",
"order": 32.5
}
],
"tunnel:stop": [
{
"group": "Services",
Expand Down
29 changes: 29 additions & 0 deletions src/lib/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ export interface StatusReport {
services: StatusServiceRow[];
}

export interface SandboxStatusReport {
schemaVersion: 1;
sandbox: StatusSandboxRow | null;
gatewayHealth: GatewayHealth | null;
services: StatusServiceRow[];
}

function safeStatusString(value: string | null | undefined): string | null {
if (typeof value !== "string" || value.length === 0) return null;
return redactFull(value);
Expand Down Expand Up @@ -388,6 +395,28 @@ export function getStatusReport(deps: ShowStatusCommandDeps): StatusReport {
};
}

export function getSandboxStatusReport(
deps: ShowStatusCommandDeps,
sandboxName: string,
): SandboxStatusReport {
const { sandboxes, defaultSandbox } = deps.listSandboxes();
const resolvedDefault = defaultSandbox || null;
const target = sandboxes.find((sandbox) => sandbox.name === sandboxName) ?? null;
const liveInference =
target && target.name === resolvedDefault && sandboxes.length > 0 ? deps.getLiveInference() : null;
const gatewayHealth =
deps.getGatewayHealth && target ? deps.getGatewayHealth() : null;
const services =
deps.getServiceStatuses?.({ sandboxName }).map(normalizeServiceStatus) ?? [];

return {
schemaVersion: 1,
sandbox: target ? buildStatusSandboxRow(target, resolvedDefault, liveInference) : null,
gatewayHealth: normalizeGatewayHealth(gatewayHealth),
services,
};
}

/**
* Render the `nemoclaw status` output (no sandbox name): a compact per-row
* listing followed by gateway/service status and messaging-bridge warnings.
Expand Down
Loading