Skip to content
6 changes: 5 additions & 1 deletion scripts/checks/run-managed-image-openshell-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveAgent } from "../../src/lib/agent/onboard.ts";
import { parseOpenShellSandboxId } from "../../src/lib/adapters/openshell/sandbox-identity.ts";
import { createCliOpenShellSandboxObserverFromRunner } from "../../src/lib/adapters/openshell/sandbox-observer-cli.ts";
import { isValidName, NAME_ALLOWED_FORMAT } from "../../src/lib/name-validation.ts";
import {
type StopHostGatewayResult,
Expand Down Expand Up @@ -1049,6 +1050,7 @@ async function run<T extends ManagedImageOpenShellE2eLocalInferenceEvidence = ne
{
runOpenshell: onboard.runOpenshell,
runCaptureOpenshell: onboard.runCaptureOpenshell,
sandboxObserver: createCliOpenShellSandboxObserverFromRunner(onboard.runOpenshell),
sleep: onboard.sleepSeconds,
openshellArgv: onboard.openshellArgv,
verifyDirectSandboxGpu,
Expand Down Expand Up @@ -1100,7 +1102,9 @@ async function run<T extends ManagedImageOpenShellE2eLocalInferenceEvidence = ne
throw new Error("production managed-bootstrap flow returned no result");
}
if (flow.origin !== "created") {
throw new Error("production managed-bootstrap flow unexpectedly resumed an existing sandbox");
throw new Error(
"production managed-bootstrap flow unexpectedly resumed an existing sandbox",
);
}
const expectedRoute = gpuEnabled ? "native" : "none";
if (flow.route !== expectedRoute || flow.createResult.status !== 0) {
Expand Down
10 changes: 7 additions & 3 deletions src/lib/adapters/openshell/sandbox-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,14 @@ export function fingerprintOpenShellSandboxLiveIdentity(output: string): string
export function resolveOpenShellSandboxId(
sandboxName: string,
runCaptureOpenshell: (args: string[], options?: Record<string, unknown>) => string,
gatewayName?: string,
): string {
const output = runCaptureOpenshell(["sandbox", "get", sandboxName], {
ignoreError: false,
});
const output = runCaptureOpenshell(
["sandbox", "get", ...(gatewayName ? ["-g", gatewayName] : []), sandboxName],
{
ignoreError: false,
},
);
const sandboxId = parseOpenShellSandboxId(output);
if (!sandboxId) {
throw new Error(
Expand Down
95 changes: 95 additions & 0 deletions src/lib/adapters/openshell/sandbox-observer-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import { describe, expect, it, vi } from "vitest";

import {
createCliOpenShellLegacyPodReadinessProbe,
createCliOpenShellSandboxLookup,
createCliOpenShellSandboxObserver as createObserver,
createCliOpenShellSandboxObserverFromRunner,
type CapturedSandboxCommandResult,
type CliOpenShellSandboxObserverDeps,
parseCliOpenShellSandboxInventory,
Expand Down Expand Up @@ -94,6 +96,24 @@ describe("CLI OpenShell sandbox observer", () => {
});
});

it("parses the captured DGX Spark readiness sequence inside the CLI implementation (#9803)", () => {
const rows = [
"my-sandbox Provisioning 2s ago",
"my-sandbox Error 6s ago",
"my-sandbox Error 8s ago",
"my-sandbox Error 10s ago",
"my-sandbox Ready 14s ago",
];

expect(rows.map((row) => parseCliOpenShellSandboxInventory(row).sandboxes[0]?.phase)).toEqual([
"Provisioning",
"Error",
"Error",
"Error",
"Ready",
]);
});

it("parses successful list output from stdout without treating stderr as inventory (#9803)", async () => {
const observer = createCliOpenShellSandboxObserver({
capture: () => captured(0, "alpha Ready", "warning text"),
Expand Down Expand Up @@ -219,4 +239,79 @@ describe("CLI OpenShell sandbox observer", () => {
error: { kind: "timeout", message: "OpenShell sandbox observation timed out." },
});
});

it("normalizes a structured runner without exposing runner output to consumers (#9803)", async () => {
const run = vi.fn(() => ({
status: 0,
stdout: Buffer.from("alpha Ready"),
stderr: Buffer.from("warning text"),
}));
const observer = createCliOpenShellSandboxObserverFromRunner(run, 9_000);

await expect(
observer.listSandboxes({ target: namedOpenShellGateway("nemoclaw") }),
).resolves.toEqual({
ok: true,
value: {
sandboxes: [{ name: "alpha", phase: "Ready", readiness: "ready" }],
},
});
expect(run).toHaveBeenCalledWith(["sandbox", "list", "-g", "nemoclaw"], {
ignoreError: true,
suppressOutput: true,
timeout: 9_000,
});
});

it("keeps the legacy Kubernetes readiness command and phase parsing in the CLI implementation (#9803)", async () => {
const capture = vi.fn(() => captured(0, "Running"));
const probe = createCliOpenShellLegacyPodReadinessProbe({
capture,
defaultTimeoutMs: 7_000,
});

await expect(
probe({
target: namedOpenShellGateway("nemoclaw"),
sandboxName: "alpha",
}),
).resolves.toEqual({ ok: true, value: "ready" });
expect(capture).toHaveBeenCalledWith(
[
"doctor",
"exec",
"--",
"kubectl",
"-n",
"openshell",
"get",
"pod",
"alpha",
"-o",
"jsonpath={.status.phase}",
],
{
ignoreError: true,
includeStderr: true,
includeStreams: true,
timeout: 7_000,
},
);
});

it("returns a typed legacy Kubernetes observation failure (#9803)", async () => {
const probe = createCliOpenShellLegacyPodReadinessProbe({
capture: () => captured(1, "", "authentication failed"),
});

await expect(
probe({ target: selectedOpenShellGateway(), sandboxName: "alpha" }),
).resolves.toEqual({
ok: false,
error: {
kind: "authentication",
message: "OpenShell could not authenticate the sandbox observation.",
},
});
});
});
73 changes: 73 additions & 0 deletions src/lib/adapters/openshell/sandbox-observer-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type OpenShellSandboxLookup,
type OpenShellSandboxObservation,
type OpenShellSandboxObserver,
type OpenShellSandboxReadinessProbe,
type OpenShellSandboxResult,
} from "./sandbox-observer";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./timeouts";
Expand Down Expand Up @@ -68,6 +69,16 @@ export type CliOpenShellSandboxObserverDeps = Readonly<{
defaultTimeoutMs?: number;
}>;

export type RunSandboxCommand = (
args: string[],
options: { ignoreError: true; suppressOutput: true; timeout: number },
) => Readonly<{
status?: number | null;
stdout?: string | Buffer | null;
stderr?: string | Buffer | null;
error?: Error | null;
}>;

export type CliOpenShellSandboxLookupResult = Readonly<{
result: OpenShellSandboxResult<OpenShellSandboxLookup>;
displayOutput: string;
Expand Down Expand Up @@ -209,6 +220,68 @@ function failure<T>(error: OpenShellSandboxError): OpenShellSandboxResult<T> {
return { ok: false, error };
}

function streamText(value: string | Buffer | null | undefined): string {
return value == null ? "" : String(value);
}

/** Normalize structured runner results inside the CLI implementation. */
export function createCliOpenShellSandboxObserverFromRunner(
run: RunSandboxCommand,
defaultTimeoutMs?: number,
): OpenShellSandboxObserver {
return createCliOpenShellSandboxObserver({
capture: (args, options) => {
const result = run(args, {
ignoreError: true,
suppressOutput: true,
timeout: options.timeout,
});
const stdout = streamText(result.stdout);
const stderr = streamText(result.stderr);
return {
status: result.status ?? null,
output: `${stdout}${stderr}`.trim(),
stdout,
stderr,
...(result.error ? { error: result.error } : {}),
};
},
...(defaultTimeoutMs === undefined ? {} : { defaultTimeoutMs }),
});
}

/** CLI-only fallback for legacy gateways that publish readiness through Kubernetes pod phase. */
export function createCliOpenShellLegacyPodReadinessProbe(
deps: CliOpenShellSandboxObserverDeps,
): OpenShellSandboxReadinessProbe {
return async (request) => {
const result = await deps.capture(
[
"doctor",
"exec",
"--",
"kubectl",
"-n",
"openshell",
"get",
"pod",
request.sandboxName,
"-o",
"jsonpath={.status.phase}",
],
{
ignoreError: true,
includeStderr: true,
includeStreams: true,
timeout: request.timeoutMs ?? deps.defaultTimeoutMs ?? OPENSHELL_PROBE_TIMEOUT_MS,
},
);
const error = classifyCliOpenShellCommandError(result);
if (error) return failure(error);
return success(successfulCommandOutput(result).trim() === "Running" ? "ready" : "not_ready");
};
}

/**
* CLI-only compatibility lookup for the legacy status display. Presence and
* phase decisions must use `result`; `displayOutput` remains a CLI-only
Expand Down
4 changes: 4 additions & 0 deletions src/lib/adapters/openshell/sandbox-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export type LookupOpenShellSandboxRequest = ListOpenShellSandboxesRequest &
sandboxName: string;
}>;

export type OpenShellSandboxReadinessProbe = (
request: LookupOpenShellSandboxRequest,
) => Promise<OpenShellSandboxResult<OpenShellSandboxReadiness>>;

/** Transport-neutral sandbox observation capabilities used by NemoClaw. */
export interface OpenShellSandboxObserver {
listSandboxes(
Expand Down
6 changes: 3 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,10 +721,10 @@ const {

// Gateway state functions — delegated to src/lib/state/gateway.ts
const { isSandboxReady, parseSandboxStatus, getSandboxStateFromOutputs } = gatewayState;
const waitForSandboxReady = sandboxReadinessTracing.createSandboxReadyWaiter({
runCaptureOpenshell,
isSandboxReady,
const waitForSandboxReady = sandboxReadinessTracing.createCliSandboxReadyWaiter({
isLinuxDockerDriverGatewayEnabled,
capture: captureOpenshell,
getGatewayName: () => GATEWAY_NAME,
sleep: sleepSeconds,
});
const { hasStaleGateway, isSelectedGateway, isGatewayHealthy, getGatewayReuseState } =
Expand Down
52 changes: 46 additions & 6 deletions src/lib/onboard/__test-helpers__/sandbox-gpu-create-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "node:path";

import { expect, vi } from "vitest";

import { createCliOpenShellSandboxObserver } from "../../adapters/openshell/sandbox-observer-cli";
import type { CheckpointPortableRuntimeAuthority } from "../../state/onboard-checkpoint-types";
import type { SandboxGpuProofResult } from "../../state/registry";
import type { ManagedBootstrapRuntimeCreateLifecycleInput } from "../managed-bootstrap/runtime-create";
Expand Down Expand Up @@ -57,18 +58,57 @@ export function createGpuFlowInput(): SandboxGpuCreateFlowInput {
};
}

export function createGpuFlowDeps(sandboxId = "alpha-sandbox-id"): SandboxGpuCreateFlowDeps {
export function createGpuFlowDeps(sandboxId?: string): SandboxGpuCreateFlowDeps;
export function createGpuFlowDeps(
expectedGatewayName: string,
requireTargetedSandboxProbes: boolean,
): SandboxGpuCreateFlowDeps;
export function createGpuFlowDeps(
sandboxIdOrGatewayName = "alpha-sandbox-id",
expectedGatewayNameOrRequireTargetedProbes: string | boolean = "nemoclaw",
): SandboxGpuCreateFlowDeps {
const requiresTargetedSandboxProbes =
typeof expectedGatewayNameOrRequireTargetedProbes === "boolean";
const sandboxId = requiresTargetedSandboxProbes ? "alpha-sandbox-id" : sandboxIdOrGatewayName;
const expectedGatewayName = requiresTargetedSandboxProbes
? sandboxIdOrGatewayName
: expectedGatewayNameOrRequireTargetedProbes;
const assertSandboxProbeTarget = (args: readonly string[]) => {
if (!requiresTargetedSandboxProbes) return;
if (args[0] !== "sandbox" || !["exec", "get", "list"].includes(args[1] ?? "")) return;
const gatewayFlag = args.indexOf("-g");
expect(gatewayFlag).toBeGreaterThan(1);
expect(args[gatewayFlag + 1]).toBe(expectedGatewayName);
};
const runCaptureOpenshell = vi.fn((args: string[], _options?: Record<string, unknown>) => {
assertSandboxProbeTarget(args);
if (args[0] === "sandbox" && args[1] === "get") {
return `Name: alpha\nId: ${sandboxId}\nState: Ready\n`;
}
if (args[0] === "sandbox" && args[1] === "list") return "alpha Ready";
return "";
});
return {
runOpenshell: vi.fn((args: string[]) =>
args[0] === "sandbox" && args[1] === "get"
runOpenshell: vi.fn((args: string[]) => {
assertSandboxProbeTarget(args);
return args[0] === "sandbox" && args[1] === "get"
? {
status: 0,
stdout: `Name: alpha\nId: ${sandboxId}\nState: Ready\n`,
stderr: "",
}
: { status: 0, stdout: "", stderr: "" },
),
runCaptureOpenshell: vi.fn(() => "alpha Ready"),
: { status: 0, stdout: "", stderr: "" };
}),
runCaptureOpenshell,
sandboxObserver: createCliOpenShellSandboxObserver({
capture: (args, options) => {
const stdout = runCaptureOpenshell(args, {
ignoreError: true,
timeout: options.timeout,
});
return { status: 0, output: stdout, stdout, stderr: "" };
},
}),
sleep: vi.fn(),
openshellArgv: vi.fn((args: string[]) => ["openshell", ...args]),
verifyDirectSandboxGpu: vi.fn(() => VERIFIED_GPU_PROOF),
Expand Down
Loading