Skip to content
Closed
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 docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ If the session lacks that registration receipt, the provider is missing, or its
</AgentOnly>

Completed onboarding sessions are not resumable.
Use `--resume` only for interrupted `in_progress` sessions, not to change provider, model, agent, or sandbox recreation settings after onboarding has completed.
Use `--resume` only for resumable interrupted or failed sessions, not to change provider, model, agent, or sandbox recreation settings after onboarding has completed.
During resume, NemoClaw reruns preflight, gateway, provider, and sandbox repair checks even when the saved session has already reached a later nonterminal onboarding phase.
If the recorded session conflicts with flags you pass on the recovery run, NemoClaw exits and tells you to either rerun with the original settings or start over.

Expand Down
2 changes: 2 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3088,6 +3088,7 @@ activate_express_install() {
export NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt
export NEMOCLAW_YES=1
export NEMOCLAW_POLICY_MODE=suggested
unset NEMOCLAW_STATION_EXPRESS
case "$platform" in
"DGX Spark")
export NEMOCLAW_SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}"
Expand All @@ -3097,6 +3098,7 @@ activate_express_install() {
fi
;;
"DGX Station")
export NEMOCLAW_STATION_EXPRESS=1
export NEMOCLAW_SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}"
export NEMOCLAW_PROVIDER=install-vllm
configure_station_express_model
Expand Down
6 changes: 3 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4000,7 +4000,7 @@ async function preflightAuthoritativeRebuildTarget(
}

// ── Main ─────────────────────────────────────────────────────────
const onboard = onboardEntryOptions.withNonInteractiveEnvironment(runOnboard);
const onboard = onboardEntryOptions.wrapOnboard(runOnboard, onboardSession.loadSession);
async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
setupInferenceFactory.assertNoOpenShellGatewayEndpointOverride();
const runtimeControlRequests = runtimeControlFlow.applyOnboardRuntimeControlRequests(opts);
Expand Down Expand Up @@ -4055,7 +4055,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
}
// Validate provider/model hints before preflight so configuration errors are not reported as Docker failures.
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
resumeConfig.preflightEarlyOnboardEnvForResume(isNonInteractive(), opts.authoritativeResumeConfig === true);
const stationSessionInput = onboardEntryOptions.prepareSessionInput(runtimeControlRequests, requestedSandboxName, resume, () => resumeConfig.preflightEarlyOnboardEnvForResume(isNonInteractive(), opts.authoritativeResumeConfig === true));
const ownsOnboardLock = opts.onboardLockAlreadyHeld !== true;
const lockResult = ownsOnboardLock
? onboardSession.acquireOnboardLock(
Expand Down Expand Up @@ -4148,7 +4148,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
authoritativeResumeConfig: opts.authoritativeResumeConfig === true,
agentFlag: opts.agent || null,
envAgent: process.env.NEMOCLAW_AGENT || null,
...runtimeControlRequests,
...stationSessionInput,
},
{
loadSession: onboardSession.loadSession,
Expand Down
27 changes: 27 additions & 0 deletions src/lib/onboard/entry-options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
requireStationExpressResumeIntent,
type StationExpressSessionLike,
wrapOnboard as wrapStationExpressOnboard,
} from "./station-express-resume";

export interface OnboardEntryOptionsInput {
opts: {
resume?: boolean;
Expand Down Expand Up @@ -44,6 +50,7 @@ export interface ResolvedOnboardEntryOptions {
}

type NonInteractiveEntryOptions = { nonInteractive?: boolean };
type ResumableEntryOptions = NonInteractiveEntryOptions & { resume?: boolean; fresh?: boolean };

/** Scope the CLI flag to helpers that still read the compatibility environment variable. */
export function withNonInteractiveEnvironment<Options extends NonInteractiveEntryOptions>(
Expand All @@ -64,6 +71,26 @@ export function withNonInteractiveEnvironment<Options extends NonInteractiveEntr
};
}

export function wrapOnboard<Options extends ResumableEntryOptions>(
run: (options?: Options) => Promise<void>,
loadSession: () => StationExpressSessionLike | null,
): (options?: Options) => Promise<void> {
return wrapStationExpressOnboard(withNonInteractiveEnvironment(run), loadSession);
}

export function prepareSessionInput<RuntimeControlRequests extends object>(
runtimeControlRequests: RuntimeControlRequests,
sandboxName: string | null,
resume: boolean,
preflight: () => void,
) {
preflight();
return {
...runtimeControlRequests,
stationExpressIntent: requireStationExpressResumeIntent(process.env, sandboxName, resume),
};
}

export function resolveOnboardEntryOptions(
input: OnboardEntryOptionsInput,
deps: OnboardEntryOptionsDeps,
Expand Down
28 changes: 28 additions & 0 deletions src/lib/onboard/machine/handlers/provider-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,34 @@ describe("handleProviderInferenceState", () => {
expect(calls.deleteEnv).toHaveBeenCalledWith("COMPATIBLE_API_KEY");
});

it("retains Station Express intent without committing a failed managed provider selection", async () => {
const setupNim = vi.fn(async () => {
throw new Error("injected managed vLLM download failure");
});
const { deps, calls } = createDeps({ setupNim });
const session = createSession({
mode: "non-interactive",
stationExpressIntent: {
version: 1,
model: "nemotron-3-ultra-550b-a55b",
sandboxName: "my-assistant",
},
});

await expect(handleProviderInferenceState(baseOptions(deps, session))).rejects.toThrow(
"injected managed vLLM download failure",
);

expect(session.stationExpressIntent).toEqual({
version: 1,
model: "nemotron-3-ultra-550b-a55b",
sandboxName: "my-assistant",
});
expect(session.provider).toBeNull();
expect(session.model).toBeNull();
expect(calls.complete).not.toHaveBeenCalledWith("provider_selection", expect.anything());
});

it("exits through the injected CLI boundary when provider selection is incomplete", async () => {
const setupNim = vi.fn(async () => ({ ...baseSelection, model: null }));
const { deps, calls } = createDeps({ setupNim });
Expand Down
26 changes: 26 additions & 0 deletions src/lib/onboard/session-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ describe("prepareOnboardSession", () => {
expect(getSession()?.sessionId).not.toBe("old-session");
});

it("checkpoints Station Express choices before managed vLLM setup", async () => {
const { deps } = createDeps();
const stationExpress = {
version: 1 as const,
model: "nemotron-3-ultra-550b-a55b",
sandboxName: "my-assistant",
};

const result = await prepareOnboardSession(
{
resume: false,
fresh: false,
requestedFromDockerfile: null,
requestedSandboxName: "my-assistant",
cannotPrompt: true,
nonInteractive: true,
stationExpressIntent: stationExpress,
},
deps,
);

expect(result.session?.stationExpressIntent).toEqual(stationExpress);
expect(result.session?.provider).toBeNull();
expect(result.session?.model).toBeNull();
});

it("defaults a fresh session to progressive disclosure", async () => {
const { deps } = createDeps();
const result = await prepareOnboardSession(
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/session-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type { Session } from "../state/onboard-session";
import { DEFAULT_TOOL_DISCLOSURE, type ToolDisclosure } from "../tool-disclosure";
import type { ResumeConfigConflict } from "./resume-config";
import type { StationExpressResumeIntent } from "./station-express-resume";

export interface OnboardSessionBootstrapInput {
resume: boolean;
Expand All @@ -17,6 +18,7 @@ export interface OnboardSessionBootstrapInput {
envAgent?: string | null;
requestedToolDisclosure?: ToolDisclosure | null;
requestedObservabilityEnabled?: boolean | null;
stationExpressIntent?: StationExpressResumeIntent | null;
}

export interface OnboardSessionBootstrapDeps {
Expand Down Expand Up @@ -225,6 +227,7 @@ function prepareFreshSession(
toolDisclosure: input.requestedToolDisclosure ?? DEFAULT_TOOL_DISCLOSURE,
observabilityEnabled: input.requestedObservabilityEnabled === true,
observabilityRequestedExplicitly: typeof input.requestedObservabilityEnabled === "boolean",
stationExpressIntent: input.stationExpressIntent ?? null,
metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile || null },
}),
);
Expand Down
164 changes: 164 additions & 0 deletions src/lib/onboard/station-express-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import { createSession } from "../state/onboard-session";
import {
getStationExpressResumeIntent,
parseStationExpressResumeIntent,
STATION_EXPRESS_ENV,
withStationExpressResumeEnvironment,
} from "./station-express-resume";

const ultraIntent = {
version: 1 as const,
model: "nemotron-3-ultra-550b-a55b",
sandboxName: "my-assistant",
};

function expressEnv(): NodeJS.ProcessEnv {
return {
[STATION_EXPRESS_ENV]: "1",
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_YES: "1",
NEMOCLAW_POLICY_MODE: "suggested",
NEMOCLAW_SANDBOX_NAME: "my-assistant",
NEMOCLAW_PROVIDER: "install-vllm",
NEMOCLAW_VLLM_MODEL: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4",
NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b",
};
}

function resumeDeps(
session = createSession({ mode: "non-interactive", stationExpressIntent: ultraIntent }),
) {
return {
loadSession: vi.fn(() => session),
error: vi.fn(),
exitProcess: vi.fn((code: number): never => {
throw new Error(`exit ${String(code)}`);
}),
};
}

describe("DGX Station Express resume", () => {
it("captures a canonical secret-free intent from the installer environment", () => {
expect(getStationExpressResumeIntent(expressEnv(), "my-assistant")).toEqual({
ok: true,
intent: ultraIntent,
});
});

it("ignores ordinary onboarding without the Station Express marker", () => {
expect(getStationExpressResumeIntent({}, null)).toEqual({ ok: true, intent: null });
});

it("rejects malformed or expanded persisted intent", () => {
expect(
parseStationExpressResumeIntent({ ...ultraIntent, token: "must-not-persist" }),
).toBeNull();
expect(
parseStationExpressResumeIntent({ ...ultraIntent, model: "qwen3.6-35b-a3b-nvfp4" }),
).toBeNull();
});

it("restores the saved provider and model for a plain failed-session resume", async () => {
const env: NodeJS.ProcessEnv = { NEMOCLAW_PROVIDER: "" };
const deps = resumeDeps(
createSession({
mode: "non-interactive",
status: "failed",
stationExpressIntent: ultraIntent,
}),
);
const run = vi.fn(async () => {
expect(env).toMatchObject({
NEMOCLAW_STATION_EXPRESS: "1",
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_YES: "1",
NEMOCLAW_POLICY_MODE: "suggested",
NEMOCLAW_SANDBOX_NAME: "my-assistant",
NEMOCLAW_PROVIDER: "install-vllm",
NEMOCLAW_VLLM_MODEL: "nemotron-3-ultra-550b-a55b",
NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b",
});
});

await withStationExpressResumeEnvironment(run, deps, env)({ resume: true });

expect(run).toHaveBeenCalledTimes(1);
expect(env).toEqual({ NEMOCLAW_PROVIDER: "" });
});

it("also restores an automatically resumed in-progress Express session", async () => {
const env: NodeJS.ProcessEnv = {};
const deps = resumeDeps();
const run = vi.fn(async () => {
expect(env.NEMOCLAW_PROVIDER).toBe("install-vllm");
});

await withStationExpressResumeEnvironment(run, deps, env)({});

expect(run).toHaveBeenCalledTimes(1);
expect(env).toEqual({});
});

it("reuses a completed provider selection without replaying managed installation", async () => {
const completeProviderStep = {
status: "complete" as const,
startedAt: "2026-07-16T00:00:00.000Z",
completedAt: "2026-07-16T00:01:00.000Z",
error: null,
};
const session = createSession({
mode: "non-interactive",
status: "failed",
stationExpressIntent: ultraIntent,
provider: "vllm-local",
model: "nvidia/nemotron-3-ultra-550b-a55b",
steps: {
provider_selection: completeProviderStep,
},
});
const env: NodeJS.ProcessEnv = {};
const deps = resumeDeps(session);
const run = vi.fn(async () => {
expect(env.NEMOCLAW_NON_INTERACTIVE).toBe("1");
expect(env.NEMOCLAW_POLICY_MODE).toBe("suggested");
expect(env.NEMOCLAW_PROVIDER).toBeUndefined();
expect(env.NEMOCLAW_VLLM_MODEL).toBeUndefined();
expect(env.NEMOCLAW_MODEL).toBeUndefined();
});

await withStationExpressResumeEnvironment(run, deps, env)({ resume: true });

expect(run).toHaveBeenCalledTimes(1);
expect(env).toEqual({});
});

it("does not restore discarded intent for --fresh", async () => {
const env: NodeJS.ProcessEnv = {};
const deps = resumeDeps();
const run = vi.fn(async () => {
expect(env.NEMOCLAW_PROVIDER).toBeUndefined();
});

await withStationExpressResumeEnvironment(run, deps, env)({ fresh: true });

expect(run).toHaveBeenCalledTimes(1);
});

it("fails closed when an explicit resume override selects another model", async () => {
const env: NodeJS.ProcessEnv = { NEMOCLAW_VLLM_MODEL: "deepseek-v4-flash" };
const deps = resumeDeps();
const run = vi.fn(async () => undefined);

await expect(
withStationExpressResumeEnvironment(run, deps, env)({ resume: true }),
).rejects.toThrow("exit 1");

expect(run).not.toHaveBeenCalled();
expect(deps.error).toHaveBeenCalledWith(expect.stringContaining("NEMOCLAW_VLLM_MODEL"));
});
});
Loading
Loading