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
2 changes: 1 addition & 1 deletion src/lib/actions/sandbox/gateway-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from "../../adapters/openshell/timeouts";
import * as registry from "../../state/registry";

type SandboxGatewayState = {
export type SandboxGatewayState = {
state: string;
output: string;
activeGateway?: string | null;
Expand Down
37 changes: 28 additions & 9 deletions src/lib/actions/sandbox/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../../adapters/openshell/runtime";
import * as registry from "../../state/registry";
import { resolveOpenshell } from "../../adapters/openshell/resolve";
import type { SandboxGatewayState } from "./gateway-state";
import {
getReconciledSandboxGatewayState,
getSandboxGatewayStateForStatus,
Expand Down Expand Up @@ -57,15 +58,33 @@ export function getSandboxStatusInferenceHealth(
// eslint-disable-next-line complexity
export async function showSandboxStatus(sandboxName: string): Promise<void> {
const sb = registry.getSandbox(sandboxName);
const lookup = await getReconciledSandboxGatewayState(sandboxName, {
getState: getSandboxGatewayStateForStatus,
});
const liveResult =
lookup.state === "present"
? await captureOpenshellForStatus(["inference", "get"], {
ignoreError: true,
})
: null;
// #2666: never let an unexpected throw from the gateway probe (e.g. openshell
// hanging when its container is stopped and the published port is held by a
// foreign listener) suppress the sandbox header. The downstream switch
// handles `gateway_error` by printing an actionable block + exit(1), so a
// synthesized fallback keeps the user-visible contract intact.
let lookup: SandboxGatewayState;
try {
lookup = await getReconciledSandboxGatewayState(sandboxName, {
getState: getSandboxGatewayStateForStatus,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
lookup = {
state: "gateway_error",
output: ` Could not probe live gateway state: ${message}`,
};
}
let liveResult: Awaited<ReturnType<typeof captureOpenshellForStatus>> | null = null;
if (lookup.state === "present") {
try {
liveResult = await captureOpenshellForStatus(["inference", "get"], {
ignoreError: true,
});
} catch {
liveResult = null;
}
}
const live =
liveResult && !isCommandTimeout(liveResult) ? parseGatewayInference(liveResult.output) : null;
const currentModel = (live && live.model) || (sb && sb.model) || "unknown";
Expand Down
51 changes: 48 additions & 3 deletions src/lib/cli/oclif-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,57 @@ describe("runRegisteredOclifCommand", () => {
expect(exit).toHaveBeenCalledWith(2);
});

it("treats oclif help exits as success", async () => {
runCommandMock.mockRejectedValue({ oclif: { exit: 0 } });
it("treats oclif graceful ExitError(0) as silent success", async () => {
// Mirrors what `Command.exit(0)` and `--help` actually throw in oclif: an
// ExitError instance whose synthetic `EEXIT: 0` message must NOT leak to
// the user.
class ExitError extends Error {
oclif = { exit: 0 };
}
runCommandMock.mockRejectedValue(new ExitError("EEXIT: 0"));
const errorLine = vi.fn();

await runRegisteredOclifCommand("list", ["--help"], { rootDir: "/repo", error: errorLine });

expect(process.exitCode).toBe(0);
expect(errorLine).not.toHaveBeenCalled();
});

it("#2666: surfaces errors that happen to carry oclif.exit === 0 instead of swallowing them", async () => {
// Before #2666 this branch silently set exit 0 and produced no output.
// The bug was an arbitrary error riding the same `oclif.exit === 0`
// channel, e.g. propagated from inside a command's run(). Surface the
// message so the user gets signal.
class WeirdError extends Error {
oclif = { exit: 0 };
}
runCommandMock.mockRejectedValue(new WeirdError("Could not verify sandbox 'my-assist' against the live OpenShell gateway"));
const errorLine = vi.fn();

await runRegisteredOclifCommand("status", ["my-assist"], { rootDir: "/repo", error: errorLine });

expect(process.exitCode).toBe(0);
expect(errorLine).toHaveBeenCalledWith(
" Could not verify sandbox 'my-assist' against the live OpenShell gateway",
);
});

it("#2666: falls back to a generic line when the error message is empty", async () => {
// Closes the residual silent path: if a non-ExitError(0) carries an
// empty message (or one that trims to empty), still emit *something*
// so the user is never left looking at exit 0 + blank stdout/stderr.
class BlankError extends Error {
oclif = { exit: 0 };
}
runCommandMock.mockRejectedValue(new BlankError(""));
const errorLine = vi.fn();

await runRegisteredOclifCommand("list", ["--help"], { rootDir: "/repo" });
await runRegisteredOclifCommand("status", ["my-assist"], { rootDir: "/repo", error: errorLine });

expect(process.exitCode).toBe(0);
expect(errorLine).toHaveBeenCalledOnce();
const [line] = errorLine.mock.calls[0];
expect(String(line).trim().length).toBeGreaterThan(0);
});

it("rethrows non-parse command failures", async () => {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/cli/oclif-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ export async function runRegisteredOclifCommand(
} catch (error) {
const exitCode = getOclifExitCode(error);
if (exitCode === 0) {
// #2666: only oclif's own ExitError(0) is an intentional graceful
// exit (e.g. Command.exit(0) — message is the synthetic "EEXIT: 0").
// Any OTHER error that happens to carry oclif.exit === 0 used to be
// silently swallowed here, producing exit 0 + completely empty
// stdout/stderr. Surface its message — and fall back to a generic
// line if formatOclifError() returns empty so we never reintroduce
// the silent path for an error whose message happens to be blank.
if (!isOclifExitError(error)) {
const message = formatOclifError(error) || "Command exited with no output.";
errorLine(` ${message}`);
}
process.exitCode = 0;
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/inference/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ describe("inference health", () => {
model: "moonshotai/kimi-k2.6",
messages: [{ role: "user", content: "Reply with exactly: OK" }],
max_tokens: 8,
chat_template_kwargs: { thinking: false },
});
});

Expand Down
63 changes: 54 additions & 9 deletions src/lib/list-command-deps.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0


import * as onboardSession from "./state/onboard-session";
import type { ListSandboxesCommandDeps } from "./inventory-commands";
import type { ListSandboxesCommandDeps, SandboxEntry } from "./inventory-commands";
import { parseGatewayInference } from "./inference/config";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts";
import { parseSshProcesses, createSystemDeps } from "./state/sandbox-session";
import { resolveOpenshell } from "./adapters/openshell/resolve";
import { captureOpenshell } from "./adapters/openshell/runtime";
import { recoverRegistryEntries } from "./registry-recovery-action";
import * as registry from "./state/registry";

interface RecoveredRegistry {
sandboxes: SandboxEntry[];
defaultSandbox?: string | null;
recoveredFromSession?: boolean;
recoveredFromGateway?: number;
}

interface RegistryFallback {
sandboxes: SandboxEntry[];
defaultSandbox?: string | null;
}

/**
* #2666 fallback wrapper: if the primary recovery throws (e.g. openshell
* hangs talking to a foreign port-holder), surface the registry-only
* listing instead of letting the throw propagate and silence output.
*
* Exported for direct unit testing — `buildListCommandDeps()` wires the
* real `recoverRegistryEntries` and `registry.listSandboxes` here.
*/
export async function recoverRegistryEntriesWithFallback(
primary: () => Promise<RecoveredRegistry>,
fallback: () => RegistryFallback,
): Promise<RecoveredRegistry> {
try {
return await primary();
} catch {
const list = fallback();
return { ...list, recoveredFromSession: false, recoveredFromGateway: 0 };
}
}

export function buildListCommandDeps(): ListSandboxesCommandDeps {
const opsBinList = resolveOpenshell();
Expand All @@ -30,14 +62,27 @@ export function buildListCommandDeps(): ListSandboxesCommandDeps {
};

return {
recoverRegistryEntries: () => recoverRegistryEntries(),
getLiveInference: () =>
parseGatewayInference(
captureOpenshell(["inference", "get"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
}).output,
// #2666: never let an unexpected throw from gateway-side recovery (e.g.
// openshell hanging on a foreign port-holder while its container is
// stopped) suppress the registry-only listing. The registry lives on
// disk and is independent of runtime state.
recoverRegistryEntries: () =>
recoverRegistryEntriesWithFallback(
() => recoverRegistryEntries(),
() => registry.listSandboxes(),
),
getLiveInference: () => {
try {
return parseGatewayInference(
captureOpenshell(["inference", "get"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
}).output,
);
} catch {
return null;
}
},
loadLastSession: () => onboardSession.loadSession(),
getActiveSessionCount: sessionDeps
? (name) => {
Expand Down
Loading
Loading