From b4aa7bd6f7e6971fbe4888af25ae458b13c15433 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sun, 19 Jul 2026 08:29:21 -0700 Subject: [PATCH 1/3] fix(installer): preserve Station Express resume ports Signed-off-by: Senthil Ravichandran --- docs/get-started/dgx-station-preparation.mdx | 5 +- docs/get-started/quickstart.mdx | 4 +- scripts/install.sh | 122 +++++++++++++++-- .../onboard/station-express-resume.test.ts | 50 +++++++ src/lib/onboard/station-express-resume.ts | 35 ++++- test/install-express-prompt.test.ts | 125 +++++++++++++++++- test/install-station-host-preparation.test.ts | 25 ++-- 7 files changed, 338 insertions(+), 28 deletions(-) diff --git a/docs/get-started/dgx-station-preparation.mdx b/docs/get-started/dgx-station-preparation.mdx index 4bac497c0c..0deee896b9 100644 --- a/docs/get-started/dgx-station-preparation.mdx +++ b/docs/get-started/dgx-station-preparation.mdx @@ -67,7 +67,10 @@ It also stops when systemd reports a failed unit unless the unit matches an exac Any other failed unit blocks preparation for administrator review. It does not install a host CUDA toolkit or Docker Compose. If any other existing prerequisite version differs, preparation stops instead of changing it automatically. -After changing pinned packages, the installer exits with status `10`; reboot, sign in, and run the printed command, which pins the exact accepted NemoClaw commit before resuming express setup. +Before host preparation begins, the installer stores the accepted Station Express recipe in owner-only local state. +If preparation requires a reboot or a new login, run the printed command to restore the exact NemoClaw revision, agent, model, sandbox, policy tier, and gateway, dashboard, and vLLM ports without repeating the Express prompt. +Recovery receipts created by earlier releases remain compatible. +After changing pinned packages, the installer exits with status `10`; reboot, sign in, and run that printed command to resume Express setup. ## Validate Stock DGX OS diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index b9982ea8c1..9e2627ebc7 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -196,7 +196,9 @@ Use these details when your first-run path needs more control. DGX Spark uses managed vLLM with `qwen3.6-35b-a3b-nvfp4` by default. DGX Station express install explicitly selects `nemotron-3-ultra-550b-a55b` instead of the Station managed-vLLM profile default, `deepseek-v4-flash`, and discloses the approximately `352 GB` model download before confirmation. If the host does not meet the [Station prerequisites](prerequisites/dgx-station-preparation), the installer stops before host preparation. - Generic Ubuntu preparation can change pinned packages and then exits with status `10`; reboot, sign in, and run the printed exact-commit command to resume the accepted express recipe. + Before Station host preparation begins, the installer stores the accepted Express recipe in owner-only local state. + If preparation requires a reboot or a new login, run the printed command to restore the exact revision, agent, model, sandbox, policy tier, and gateway, dashboard, and vLLM ports without repeating the Express prompt. + Generic Ubuntu preparation can change pinned packages and then exits with status `10`; reboot, sign in, and run that printed command to resume. Stock DGX OS validation checks the factory stack in place without installing packages, restarting services, or rewriting the Docker runtime. One physical DGX OS `7.5.0` GB300 validation completed, but Station remains Deferred pending repeat clean-host qualification and CI coverage. To select DeepSeek V4 Flash while retaining the one-confirmation Station express flow, run `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- --station-deepseek`. diff --git a/scripts/install.sh b/scripts/install.sh index 9fb594e114..4e35e653b3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3077,6 +3077,9 @@ _SELECTED_EXPRESS_PLATFORM="" _STATION_EXPRESS_RESUME_REVISION="" _STATION_EXPRESS_RESUME_LOADED="" _STATION_EXPRESS_RESUME_GENERATION="" +_STATION_EXPRESS_RESUME_GATEWAY_PORT="" +_STATION_EXPRESS_RESUME_DASHBOARD_PORT="" +_STATION_EXPRESS_RESUME_VLLM_PORT="" normalize_station_vllm_model() { printf "%s" "${1:-}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' @@ -3262,6 +3265,35 @@ validate_station_express_resume_generation() { [[ "${1:-}" =~ ^[0-9a-f]{32}$ ]] } +validate_station_express_resume_port() { + local port="${1:-}" + [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1024 ] && [ "$port" -le 65535 ] +} + +station_express_resume_port_value() { + local env_name="$1" fallback="$2" port + case "$env_name" in + NEMOCLAW_GATEWAY_PORT | NEMOCLAW_DASHBOARD_PORT | NEMOCLAW_VLLM_PORT) ;; + *) error "Unsupported DGX Station express resume port field: ${env_name}" ;; + esac + port="${!env_name:-$fallback}" + port="${port#"${port%%[![:space:]]*}"}" + port="${port%"${port##*[![:space:]]}"}" + validate_station_express_resume_port "$port" \ + || error "${env_name} must be an integer between 1024 and 65535." + printf '%s' "$port" +} + +validate_station_express_resume_ports_distinct() { + local gateway_port="$1" dashboard_port="$2" vllm_port="$3" + [[ "$gateway_port" != "$dashboard_port" ]] \ + || error "NEMOCLAW_GATEWAY_PORT conflicts with NEMOCLAW_DASHBOARD_PORT (${gateway_port})." + [[ "$gateway_port" != "$vllm_port" ]] \ + || error "NEMOCLAW_GATEWAY_PORT conflicts with NEMOCLAW_VLLM_PORT (${gateway_port})." + [[ "$dashboard_port" != "$vllm_port" ]] \ + || error "NEMOCLAW_DASHBOARD_PORT conflicts with NEMOCLAW_VLLM_PORT (${dashboard_port})." +} + station_express_resume_generation() { local generation [[ -r /proc/sys/kernel/random/uuid ]] \ @@ -3324,7 +3356,9 @@ assert_station_express_resume_directory_safe() { load_station_express_resume() { local state_file revision_line model_line generation_line agent_line sandbox_line policy_tier_line + local gateway_port_line dashboard_port_line vllm_port_line local line_count saved_revision current_revision saved_agent saved_sandbox saved_policy_tier current_agent + local saved_gateway_port saved_dashboard_port saved_vllm_port current_gateway_port state_file="$(station_express_resume_file)" || return 1 assert_nemoclaw_state_path_safe "$state_file" [[ -e "$state_file" || -L "$state_file" ]] || return 1 @@ -3336,6 +3370,9 @@ load_station_express_resume() { agent_line="$(sed -n '4p' "$state_file")" sandbox_line="$(sed -n '5p' "$state_file")" policy_tier_line="$(sed -n '6p' "$state_file")" + gateway_port_line="$(sed -n '7p' "$state_file")" + dashboard_port_line="$(sed -n '8p' "$state_file")" + vllm_port_line="$(sed -n '9p' "$state_file")" saved_revision="${revision_line#revision=}" NEMOCLAW_VLLM_MODEL="${model_line#model=}" _STATION_EXPRESS_RESUME_GENERATION="${generation_line#generation=}" @@ -3347,33 +3384,76 @@ load_station_express_resume() { saved_agent="${agent_line#agent=}" saved_sandbox="${sandbox_line#sandbox=}" saved_policy_tier="${policy_tier_line#policy_tier=}" + elif [[ "$line_count" == "9" ]]; then + saved_agent="${agent_line#agent=}" + saved_sandbox="${sandbox_line#sandbox=}" + saved_policy_tier="${policy_tier_line#policy_tier=}" + saved_gateway_port="${gateway_port_line#gateway_port=}" + saved_dashboard_port="${dashboard_port_line#dashboard_port=}" + saved_vllm_port="${vllm_port_line#vllm_port=}" else error "DGX Station express resume state is invalid. Remove ${state_file} and rerun the installer." fi if [[ "$revision_line" != "revision=${saved_revision}" || "$model_line" != "model=${NEMOCLAW_VLLM_MODEL}" || "$generation_line" != "generation=${_STATION_EXPRESS_RESUME_GENERATION}" ]] \ - || { [[ "$line_count" == "6" ]] && [[ "$agent_line" != "agent=${saved_agent}" || "$sandbox_line" != "sandbox=${saved_sandbox}" || "$policy_tier_line" != "policy_tier=${saved_policy_tier}" ]]; } \ + || { [[ "$line_count" != "3" ]] && [[ "$agent_line" != "agent=${saved_agent}" || "$sandbox_line" != "sandbox=${saved_sandbox}" || "$policy_tier_line" != "policy_tier=${saved_policy_tier}" ]]; } \ + || { [[ "$line_count" == "9" ]] && [[ "$gateway_port_line" != "gateway_port=${saved_gateway_port}" || "$dashboard_port_line" != "dashboard_port=${saved_dashboard_port}" || "$vllm_port_line" != "vllm_port=${saved_vllm_port}" ]]; } \ || ! validate_station_express_resume_revision "$saved_revision" \ || ! validate_station_express_resume_model "$NEMOCLAW_VLLM_MODEL" \ || ! validate_station_express_resume_generation "$_STATION_EXPRESS_RESUME_GENERATION" \ || ! validate_station_express_resume_agent "$saved_agent" \ || ! validate_station_express_resume_sandbox "$saved_sandbox" \ - || ! validate_station_express_resume_policy_tier "$saved_policy_tier"; then + || ! validate_station_express_resume_policy_tier "$saved_policy_tier" \ + || { [[ "$line_count" == "9" ]] && { ! validate_station_express_resume_port "$saved_gateway_port" || ! validate_station_express_resume_port "$saved_dashboard_port" || ! validate_station_express_resume_port "$saved_vllm_port"; }; }; then error "DGX Station express resume state is invalid. Remove ${state_file} and rerun the installer." fi + current_gateway_port="$(resolve_nemoclaw_gateway_port)" + if [[ "$line_count" != "9" ]]; then + saved_gateway_port="$current_gateway_port" + saved_dashboard_port="$(station_express_resume_port_value NEMOCLAW_DASHBOARD_PORT 18789)" + saved_vllm_port="$(station_express_resume_port_value NEMOCLAW_VLLM_PORT 8000)" + fi + validate_station_express_resume_ports_distinct \ + "$saved_gateway_port" "$saved_dashboard_port" "$saved_vllm_port" + _STATION_EXPRESS_RESUME_REVISION="$saved_revision" + _STATION_EXPRESS_RESUME_AGENT="$saved_agent" + _STATION_EXPRESS_RESUME_SANDBOX="$saved_sandbox" + _STATION_EXPRESS_RESUME_POLICY_TIER="$saved_policy_tier" + _STATION_EXPRESS_RESUME_GATEWAY_PORT="$saved_gateway_port" + _STATION_EXPRESS_RESUME_DASHBOARD_PORT="$saved_dashboard_port" + _STATION_EXPRESS_RESUME_VLLM_PORT="$saved_vllm_port" current_revision="$(station_installer_revision)" if [[ "$current_revision" != "$saved_revision" ]]; then - error "DGX Station express resume requires NemoClaw revision ${saved_revision}, but this installer is ${current_revision}. Rerun with: curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_TAG=${saved_revision} bash" + error "DGX Station Express resume requires NemoClaw revision ${saved_revision}, but this installer is ${current_revision}. Rerun with: $(station_express_resume_command)" fi current_agent="${NEMOCLAW_AGENT:-openclaw}" if [[ "$current_agent" != "$saved_agent" ]]; then error "DGX Station express resume requires NEMOCLAW_AGENT=${saved_agent}. Rerun the exact command printed after host preparation." fi + if [[ "$line_count" == "9" ]]; then + if [[ "$current_gateway_port" != "$saved_gateway_port" ]]; then + error "DGX Station express resume requires NEMOCLAW_GATEWAY_PORT=${saved_gateway_port}. Rerun the exact command printed after host preparation." + fi + if [[ -n "${NEMOCLAW_DASHBOARD_PORT:-}" ]] \ + && [[ "$(station_express_resume_port_value NEMOCLAW_DASHBOARD_PORT 18789)" != "$saved_dashboard_port" ]]; then + error "DGX Station express resume requires NEMOCLAW_DASHBOARD_PORT=${saved_dashboard_port}. Rerun the exact command printed after host preparation." + fi + if [[ -n "${NEMOCLAW_VLLM_PORT:-}" ]] \ + && [[ "$(station_express_resume_port_value NEMOCLAW_VLLM_PORT 8000)" != "$saved_vllm_port" ]]; then + error "DGX Station express resume requires NEMOCLAW_VLLM_PORT=${saved_vllm_port}. Rerun the exact command printed after host preparation." + fi + fi NEMOCLAW_SANDBOX_NAME="$saved_sandbox" NEMOCLAW_POLICY_TIER="$saved_policy_tier" + NEMOCLAW_GATEWAY_PORT="$saved_gateway_port" + NEMOCLAW_DASHBOARD_PORT="$saved_dashboard_port" + NEMOCLAW_VLLM_PORT="$saved_vllm_port" _STATION_EXPRESS_RESUME_LOADED=1 export NEMOCLAW_VLLM_MODEL export NEMOCLAW_SANDBOX_NAME export NEMOCLAW_POLICY_TIER + export NEMOCLAW_GATEWAY_PORT + export NEMOCLAW_DASHBOARD_PORT + export NEMOCLAW_VLLM_PORT export NEMOCLAW_STATION_EXPRESS_RECEIPT_GENERATION="$_STATION_EXPRESS_RESUME_GENERATION" } @@ -3381,10 +3461,21 @@ save_station_express_resume() { local state_file state_dir temp_file revision generation local model="${NEMOCLAW_VLLM_MODEL:-}" agent="${NEMOCLAW_AGENT:-openclaw}" local sandbox="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" policy_tier="${NEMOCLAW_POLICY_TIER:-balanced}" + local gateway_port dashboard_port vllm_port validate_station_express_resume_model "$model" || error "Cannot save an invalid DGX Station express model selector." validate_station_express_resume_agent "$agent" || error "Cannot save an invalid DGX Station express agent." validate_station_express_resume_sandbox "$sandbox" || error "Cannot save an invalid DGX Station express sandbox name." validate_station_express_resume_policy_tier "$policy_tier" || error "Cannot save an invalid DGX Station express policy tier." + gateway_port="$(resolve_nemoclaw_gateway_port)" + dashboard_port="$(station_express_resume_port_value NEMOCLAW_DASHBOARD_PORT 18789)" + vllm_port="$(station_express_resume_port_value NEMOCLAW_VLLM_PORT 8000)" + validate_station_express_resume_ports_distinct "$gateway_port" "$dashboard_port" "$vllm_port" + NEMOCLAW_GATEWAY_PORT="$gateway_port" + NEMOCLAW_DASHBOARD_PORT="$dashboard_port" + NEMOCLAW_VLLM_PORT="$vllm_port" + export NEMOCLAW_GATEWAY_PORT + export NEMOCLAW_DASHBOARD_PORT + export NEMOCLAW_VLLM_PORT revision="$(station_installer_revision)" state_file="$(station_express_resume_file)" || error "Could not resolve NemoClaw state for DGX Station express resume." state_dir="$(ensure_nemoclaw_state_dir)" || error "Could not prepare NemoClaw state for DGX Station express resume." @@ -3398,8 +3489,9 @@ save_station_express_resume() { rm -f "$temp_file" error "Could not secure DGX Station express resume state under ${state_dir}." } - if ! printf 'revision=%s\nmodel=%s\ngeneration=%s\nagent=%s\nsandbox=%s\npolicy_tier=%s\n' \ - "$revision" "$model" "$generation" "$agent" "$sandbox" "$policy_tier" >"$temp_file"; then + if ! printf 'revision=%s\nmodel=%s\ngeneration=%s\nagent=%s\nsandbox=%s\npolicy_tier=%s\ngateway_port=%s\ndashboard_port=%s\nvllm_port=%s\n' \ + "$revision" "$model" "$generation" "$agent" "$sandbox" "$policy_tier" \ + "$gateway_port" "$dashboard_port" "$vllm_port" >"$temp_file"; then rm -f "$temp_file" error "Could not write DGX Station express resume state under ${state_dir}." fi @@ -3413,12 +3505,17 @@ save_station_express_resume() { _STATION_EXPRESS_RESUME_AGENT="$agent" _STATION_EXPRESS_RESUME_SANDBOX="$sandbox" _STATION_EXPRESS_RESUME_POLICY_TIER="$policy_tier" + _STATION_EXPRESS_RESUME_GATEWAY_PORT="$gateway_port" + _STATION_EXPRESS_RESUME_DASHBOARD_PORT="$dashboard_port" + _STATION_EXPRESS_RESUME_VLLM_PORT="$vllm_port" } station_express_resume_command() { - printf 'curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_TAG=%s NEMOCLAW_AGENT=%s NEMOCLAW_SANDBOX_NAME=%s NEMOCLAW_POLICY_TIER=%s bash' \ + printf 'curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_TAG=%s NEMOCLAW_AGENT=%s NEMOCLAW_SANDBOX_NAME=%s NEMOCLAW_POLICY_TIER=%s NEMOCLAW_GATEWAY_PORT=%s NEMOCLAW_DASHBOARD_PORT=%s NEMOCLAW_VLLM_PORT=%s bash' \ "$_STATION_EXPRESS_RESUME_REVISION" "$_STATION_EXPRESS_RESUME_AGENT" \ - "$_STATION_EXPRESS_RESUME_SANDBOX" "$_STATION_EXPRESS_RESUME_POLICY_TIER" + "$_STATION_EXPRESS_RESUME_SANDBOX" "$_STATION_EXPRESS_RESUME_POLICY_TIER" \ + "$_STATION_EXPRESS_RESUME_GATEWAY_PORT" "$_STATION_EXPRESS_RESUME_DASHBOARD_PORT" \ + "$_STATION_EXPRESS_RESUME_VLLM_PORT" if [ "${FORCE_STATION_INSTALL:-}" = "1" ]; then printf ' -s -- --force-station-install' fi @@ -3538,6 +3635,11 @@ filter_station_host_preparation_output() { ensure_station_express_host() { [[ "${_SELECTED_EXPRESS_PLATFORM:-}" == "DGX Station" ]] || return 0 + # Publish the accepted, secret-free recipe before the helper can mutate the + # host. The same receipt then binds reboot/login continuation and onboarding + # recovery to this exact Express attempt. + save_station_express_resume + local release_state release_state="$(classify_dgx_station_release)" case "$release_state" in @@ -3563,17 +3665,15 @@ ensure_station_express_host() { ok "DGX Station host prerequisites are ready" ;; 10) - save_station_express_resume warn "DGX Station host prerequisites were installed and require a reboot." info "Run: sudo reboot" - info "After signing in again, rerun the accepted revision:" + info "After signing in again, rerun the accepted Station Express recipe:" info "$(station_express_resume_command)" exit 10 ;; 11) - save_station_express_resume warn "Docker access was granted and requires a new login session. A reboot is not required." - info "After signing in again, rerun the accepted revision:" + info "After signing in again, rerun the accepted Station Express recipe:" info "$(station_express_resume_command)" exit 11 ;; diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index 88e771379d..ea7b4ce54e 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -59,6 +59,16 @@ function currentReceiptText( return `${receiptText().trimEnd()}\nagent=${agent}\nsandbox=${sandbox}\npolicy_tier=${policyTier}\n`; } +function portReceiptText( + overrides: Partial<{ gatewayPort: string; dashboardPort: string; vllmPort: string }> = {}, +): string { + const { gatewayPort = "18081", dashboardPort = "18790", vllmPort = "18000" } = overrides; + return ( + `${currentReceiptText().trimEnd()}\n` + + `gateway_port=${gatewayPort}\ndashboard_port=${dashboardPort}\nvllm_port=${vllmPort}\n` + ); +} + function retirementClaims(home: string): string[] { const stateDir = path.join(home, ".nemoclaw"); return fs @@ -645,6 +655,46 @@ describe("DGX Station Express resume (#7048)", () => { } }); + it("accepts and retires the port-bound installer receipt (#7203)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-port-receipt-")); + const stateDir = path.join(home, ".nemoclaw"); + const receipt = path.join(stateDir, "station-express-resume"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(receipt, portReceiptText(), { mode: 0o600 }); + + try { + expect(() => + assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), + ).not.toThrow(); + retireStationExpressInstallerResume(receiptGeneration, { env: { HOME: home } }); + expect(fs.existsSync(receipt)).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it.each([ + ["gateway", { gatewayPort: "invalid" }], + ["dashboard", { dashboardPort: "1023" }], + ["vLLM", { vllmPort: "65536" }], + ["duplicate", { dashboardPort: "18081" }], + ])("rejects a port-bound installer receipt with an invalid %s port", (_field, overrides) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-port-invalid-")); + const stateDir = path.join(home, ".nemoclaw"); + const receipt = path.join(stateDir, "station-express-resume"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(receipt, portReceiptText(overrides), { mode: 0o600 }); + + try { + expect(() => + assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), + ).toThrow("installer resume state is malformed"); + expect(fs.readFileSync(receipt, "utf8")).toBe(portReceiptText(overrides)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it.each([ ["agent", { agent: "unknown-agent" }], ["sandbox", { sandbox: "Invalid Sandbox" }], diff --git a/src/lib/onboard/station-express-resume.ts b/src/lib/onboard/station-express-resume.ts index 57be2cade6..4beae6541f 100644 --- a/src/lib/onboard/station-express-resume.ts +++ b/src/lib/onboard/station-express-resume.ts @@ -82,6 +82,7 @@ const STATION_EXPRESS_RETIREMENT_CLAIM_ATTEMPTS = 3; const STATION_EXPRESS_RECEIPT_GENERATION_PATTERN = /^[0-9a-f]{32}$/; const STATION_EXPRESS_RECEIPT_REVISION_PATTERN = /^[0-9a-f]{40}$/; const STATION_EXPRESS_RETIREMENT_CLAIM_SUFFIX_PATTERN = /^[A-Za-z0-9]+$/; +const STATION_EXPRESS_RECEIPT_PORT_PATTERN = /^\d+$/; const STATION_EXPRESS_RECEIPT_AGENTS = new Set(["openclaw", "hermes", "langchain-deepagents-code"]); const STATION_EXPRESS_RECEIPT_POLICY_TIERS = new Set(["restricted", "balanced", "open"]); @@ -121,6 +122,21 @@ function validSandboxName(value: unknown): value is string { ); } +function validReceiptPort(value: string): boolean { + if (!STATION_EXPRESS_RECEIPT_PORT_PATTERN.test(value)) return false; + const port = Number(value); + return port >= 1024 && port <= 65535; +} + +function validReceiptPorts(gatewayPort: string, dashboardPort: string, vllmPort: string): boolean { + return ( + validReceiptPort(gatewayPort) && + validReceiptPort(dashboardPort) && + validReceiptPort(vllmPort) && + new Set([gatewayPort, dashboardPort, vllmPort]).size === 3 + ); +} + export function isValidStationExpressReceiptGeneration(value: unknown): value is string { return typeof value === "string" && STATION_EXPRESS_RECEIPT_GENERATION_PATTERN.test(value); } @@ -255,7 +271,24 @@ function readStationExpressInstallerResumeGeneration(stateFile: string): string validSandboxName(lines[4].slice("sandbox=".length)) && lines[5]?.startsWith("policy_tier=") && STATION_EXPRESS_RECEIPT_POLICY_TIERS.has(lines[5].slice("policy_tier=".length)); - if (!legacyFormat && !currentFormat) { + const portFormat = + lines.length === 10 && + lines[9] === "" && + lines[3]?.startsWith("agent=") && + STATION_EXPRESS_RECEIPT_AGENTS.has(lines[3].slice("agent=".length)) && + lines[4]?.startsWith("sandbox=") && + validSandboxName(lines[4].slice("sandbox=".length)) && + lines[5]?.startsWith("policy_tier=") && + STATION_EXPRESS_RECEIPT_POLICY_TIERS.has(lines[5].slice("policy_tier=".length)) && + lines[6]?.startsWith("gateway_port=") && + lines[7]?.startsWith("dashboard_port=") && + lines[8]?.startsWith("vllm_port=") && + validReceiptPorts( + lines[6].slice("gateway_port=".length), + lines[7].slice("dashboard_port=".length), + lines[8].slice("vllm_port=".length), + ); + if (!legacyFormat && !currentFormat && !portFormat) { throw new Error("DGX Station Express installer resume state is malformed."); } const revision = lines[0]?.startsWith("revision=") ? lines[0].slice("revision=".length) : ""; diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index 41bd9aa902..af5ed1a57a 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -64,6 +64,8 @@ ensure_openshell_build_deps() { :; } # Stop immediately after the real Station express prompt configures its recipe, # before setup-jetson.sh or any installation side effect can run. classify_dgx_station_release() { printf "%s" "\${EXPRESS_RELEASE_STATE:-generic-ubuntu}"; } +station_installer_revision() { printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; } +station_express_resume_generation() { printf '0123456789abcdef0123456789abcdef'; } bash() { printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s VLLM_MODEL=%s POLICY=%s YES=%s SANDBOX=%s STATION_EXPRESS=%s\\n" \ "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \ @@ -483,7 +485,7 @@ describe_express_install 'DGX Station'`, ); }); - it("preserves complete Station Express intent across a Docker-group relogin", () => { + it("pre-stages complete Station Express intent and ports before a Docker-group relogin (#7203)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-relogin-")); const revision = "a".repeat(40); const generation = "0123456789abcdef0123456789abcdef"; @@ -499,7 +501,10 @@ NEMOCLAW_VLLM_MODEL='deepseek-v4-flash' classify_dgx_station_release() { printf 'supported-ai-developer-tools'; } station_installer_revision() { printf '${revision}'; } station_express_resume_generation() { printf '${generation}'; } -run_station_host_preparation() { return 11; } +run_station_host_preparation() { + [[ -f "$(station_express_resume_file)" ]] && printf 'RECEIPT_PRESTAGED=yes\n' + return 11 +} ensure_station_express_host`, ], { @@ -512,20 +517,130 @@ ensure_station_express_host`, NEMOCLAW_AGENT: "Hermes", NEMOCLAW_SANDBOX_NAME: "custom-agent", NEMOCLAW_POLICY_TIER: "restricted", + NEMOCLAW_GATEWAY_PORT: "18081", + NEMOCLAW_DASHBOARD_PORT: "18790", + NEMOCLAW_VLLM_PORT: "18000", }, }, ); const output = `${result.stdout}${result.stderr}`; expect(result.status, output).toBe(11); - expect(fs.readFileSync(path.join(home, ".nemoclaw", "station-express-resume"), "utf8")).toBe( + expect(output).toContain("RECEIPT_PRESTAGED=yes"); + expect( + fs.readFileSync( + path.join(home, ".nemoclaw", "gateways", "18081", "station-express-resume"), + "utf8", + ), + ).toBe( `revision=${revision}\nmodel=deepseek-v4-flash\ngeneration=${generation}\n` + - "agent=hermes\nsandbox=custom-agent\npolicy_tier=restricted\n", + "agent=hermes\nsandbox=custom-agent\npolicy_tier=restricted\n" + + "gateway_port=18081\ndashboard_port=18790\nvllm_port=18000\n", ); expect(output).toContain("A reboot is not required"); expect(output).toContain( - `NEMOCLAW_INSTALL_TAG=${revision} NEMOCLAW_AGENT=hermes NEMOCLAW_SANDBOX_NAME=custom-agent NEMOCLAW_POLICY_TIER=restricted bash`, + `NEMOCLAW_INSTALL_TAG=${revision} NEMOCLAW_AGENT=hermes NEMOCLAW_SANDBOX_NAME=custom-agent NEMOCLAW_POLICY_TIER=restricted NEMOCLAW_GATEWAY_PORT=18081 NEMOCLAW_DASHBOARD_PORT=18790 NEMOCLAW_VLLM_PORT=18000 bash`, + ); + }); + + it("does not invoke Station host preparation when the accepted receipt cannot be staged (#7203)", () => { + const { result, output } = runInstallerSourced(` +mkdir "$HOME/receipt-target" +ln -s "$HOME/receipt-target" "$HOME/.nemoclaw" +_SELECTED_EXPRESS_PLATFORM='DGX Station' +NEMOCLAW_VLLM_MODEL='deepseek-v4-flash' +station_installer_revision() { printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; } +station_express_resume_generation() { printf '0123456789abcdef0123456789abcdef'; } +classify_dgx_station_release() { printf 'supported-ai-developer-tools'; } +run_station_host_preparation() { printf 'HOST_PREPARATION_INVOKED\n'; } +ensure_station_express_host +`); + + expect(result.status, output).not.toBe(0); + expect(output).toContain("Refusing symbolic link in NemoClaw state path"); + expect(output).not.toContain("HOST_PREPARATION_INVOKED"); + }); + + it("restores custom Station Express ports from the accepted receipt without another prompt (#7203)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-port-resume-")); + const revision = "a".repeat(40); + const generation = "0123456789abcdef0123456789abcdef"; + const stateDir = path.join(home, ".nemoclaw", "gateways", "18081"); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(path.join(home, ".nemoclaw"), 0o700); + fs.chmodSync(path.join(home, ".nemoclaw", "gateways"), 0o700); + fs.writeFileSync( + path.join(stateDir, "station-express-resume"), + `revision=${revision}\nmodel=deepseek-v4-flash\ngeneration=${generation}\n` + + "agent=hermes\nsandbox=custom-agent\npolicy_tier=restricted\n" + + "gateway_port=18081\ndashboard_port=18790\nvllm_port=18000\n", + { mode: 0o600 }, + ); + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `source "$INSTALLER_UNDER_TEST" >/dev/null +detect_express_platform() { printf 'DGX Station'; } +station_installer_revision() { printf '${revision}'; } +NON_INTERACTIVE='' +NEMOCLAW_PROVIDER='' +NEMOCLAW_NO_EXPRESS='' +maybe_offer_express_install +printf 'PORTS gateway=%s dashboard=%s vllm=%s\n' "$NEMOCLAW_GATEWAY_PORT" "$NEMOCLAW_DASHBOARD_PORT" "$NEMOCLAW_VLLM_PORT"`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: TEST_SYSTEM_PATH, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + NEMOCLAW_AGENT: "hermes", + NEMOCLAW_GATEWAY_PORT: "18081", + }, + }, + ); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); + expect(output).toContain("Resuming the accepted express install"); + expect(output).not.toContain("Run express install with these settings?"); + expect(output).toContain("PORTS gateway=18081 dashboard=18790 vllm=18000"); + + const mismatched = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `source "$INSTALLER_UNDER_TEST" >/dev/null +detect_express_platform() { printf 'DGX Station'; } +station_installer_revision() { printf '${revision}'; } +NON_INTERACTIVE='' +NEMOCLAW_PROVIDER='' +NEMOCLAW_NO_EXPRESS='' +maybe_offer_express_install`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: TEST_SYSTEM_PATH, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + NEMOCLAW_AGENT: "hermes", + NEMOCLAW_GATEWAY_PORT: "18081", + NEMOCLAW_DASHBOARD_PORT: "19999", + }, + }, ); + const mismatchedOutput = `${mismatched.stdout}${mismatched.stderr}`; + expect(mismatched.status, mismatchedOutput).not.toBe(0); + expect(mismatchedOutput).toContain("requires NEMOCLAW_DASHBOARD_PORT=18790"); + expect(mismatchedOutput).not.toContain("Run express install with these settings?"); }); it("allows a matching explicit DeepSeek model with the Station demo override", () => { diff --git a/test/install-station-host-preparation.test.ts b/test/install-station-host-preparation.test.ts index 592a75a151..a49ee25034 100644 --- a/test/install-station-host-preparation.test.ts +++ b/test/install-station-host-preparation.test.ts @@ -7,7 +7,6 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { - assertStationExpressInstallerResumeMatches, clearStationExpressInstallerResume, withStationExpressResumeEnvironment, } from "../src/lib/onboard/station-express-resume"; @@ -957,7 +956,12 @@ if [ "\${1:-}" = "init" ]; then set -euo pipefail source "\${INSTALLER_UNDER_TEST:?}" >/dev/null SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" -maybe_offer_express_install() { _SELECTED_EXPRESS_PLATFORM='DGX Station'; } +maybe_offer_express_install() { + _SELECTED_EXPRESS_PLATFORM='DGX Station' + NEMOCLAW_VLLM_MODEL='nemotron-3-ultra-550b-a55b' +} +station_installer_revision() { printf '${STATION_REVISION}'; } +station_express_resume_generation() { printf '${STATION_GENERATION}'; } ensure_docker() { printf 'ENSURE_DOCKER\\n'; } ensure_openshell_build_deps() { printf 'ENSURE_BUILD_DEPS\\n'; } prepare_installer_host @@ -1073,30 +1077,33 @@ prepare_installer_host expect(result.stdout.trim().split("\n")).toEqual(["ENSURE_DOCKER", "ENSURE_BUILD_DEPS"]); }); - it("persists the selected model when host preparation requires a reboot", () => { + it("persists the selected model and ports when host preparation requires a reboot (#7203)", () => { const { home, result, output } = runSourced( INSTALLER_PAYLOAD, ` _SELECTED_EXPRESS_PLATFORM='DGX Station' NEMOCLAW_VLLM_MODEL='nemotron-3-ultra-550b-a55b' +NEMOCLAW_GATEWAY_PORT='18081' +NEMOCLAW_DASHBOARD_PORT='18790' +NEMOCLAW_VLLM_PORT='18000' station_installer_revision() { printf '${STATION_REVISION}'; } station_express_resume_generation() { printf '${STATION_GENERATION}'; } run_station_host_preparation() { return 10; } ensure_station_express_host `, ); - const stateFile = path.join(home, ".nemoclaw", "station-express-resume"); + const stateFile = path.join(home, ".nemoclaw", "gateways", "18081", "station-express-resume"); expect(result.status, output).toBe(10); expect(fs.readFileSync(stateFile, "utf-8")).toBe( `revision=${STATION_REVISION}\nmodel=nemotron-3-ultra-550b-a55b\ngeneration=${STATION_GENERATION}\n` + - "agent=openclaw\nsandbox=my-assistant\npolicy_tier=balanced\n", + "agent=openclaw\nsandbox=my-assistant\npolicy_tier=balanced\n" + + "gateway_port=18081\ndashboard_port=18790\nvllm_port=18000\n", ); expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600); - expect(() => - assertStationExpressInstallerResumeMatches(STATION_GENERATION, { HOME: home }), - ).not.toThrow(); - expect(output).toContain(`NEMOCLAW_INSTALL_TAG=${STATION_REVISION}`); + expect(output).toContain( + `NEMOCLAW_INSTALL_TAG=${STATION_REVISION} NEMOCLAW_AGENT=openclaw NEMOCLAW_SANDBOX_NAME=my-assistant NEMOCLAW_POLICY_TIER=balanced NEMOCLAW_GATEWAY_PORT=18081 NEMOCLAW_DASHBOARD_PORT=18790 NEMOCLAW_VLLM_PORT=18000 bash`, + ); }); it("rejects a resume-state symlink without loading its target", () => { From 9bef8977d15b9df7177740fdd06e3893fd997be7 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Sun, 19 Jul 2026 08:48:54 -0700 Subject: [PATCH 2/3] fix(installer): normalize Station resume ports Signed-off-by: Senthil Ravichandran --- scripts/install.sh | 6 +++++- .../onboard/station-express-resume.test.ts | 1 + src/lib/onboard/station-express-resume.ts | 3 ++- test/install-express-prompt.test.ts | 19 +++++++++++++++++++ test/install-gateway-state-root.test.ts | 18 ++++++++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 745cced538..26414f9839 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -200,6 +200,7 @@ resolve_nemoclaw_gateway_port() { if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then error "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535." fi + port="$((10#$port))" if [ "$port" -ge 18789 ] && [ "$port" -le 18799 ]; then error "NEMOCLAW_GATEWAY_PORT must not overlap the 18789-18799 dashboard port range." fi @@ -3287,11 +3288,14 @@ station_express_resume_port_value() { port="${port%"${port##*[![:space:]]}"}" validate_station_express_resume_port "$port" \ || error "${env_name} must be an integer between 1024 and 65535." - printf '%s' "$port" + printf '%s' "$((10#$port))" } validate_station_express_resume_ports_distinct() { local gateway_port="$1" dashboard_port="$2" vllm_port="$3" + gateway_port="$((10#$gateway_port))" + dashboard_port="$((10#$dashboard_port))" + vllm_port="$((10#$vllm_port))" [[ "$gateway_port" != "$dashboard_port" ]] \ || error "NEMOCLAW_GATEWAY_PORT conflicts with NEMOCLAW_DASHBOARD_PORT (${gateway_port})." [[ "$gateway_port" != "$vllm_port" ]] \ diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index ea7b4ce54e..4d74e5ce84 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -678,6 +678,7 @@ describe("DGX Station Express resume (#7048)", () => { ["dashboard", { dashboardPort: "1023" }], ["vLLM", { vllmPort: "65536" }], ["duplicate", { dashboardPort: "18081" }], + ["numerically duplicate", { dashboardPort: "018081" }], ])("rejects a port-bound installer receipt with an invalid %s port", (_field, overrides) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-port-invalid-")); const stateDir = path.join(home, ".nemoclaw"); diff --git a/src/lib/onboard/station-express-resume.ts b/src/lib/onboard/station-express-resume.ts index 4beae6541f..85cacd3dcf 100644 --- a/src/lib/onboard/station-express-resume.ts +++ b/src/lib/onboard/station-express-resume.ts @@ -129,11 +129,12 @@ function validReceiptPort(value: string): boolean { } function validReceiptPorts(gatewayPort: string, dashboardPort: string, vllmPort: string): boolean { + const numericPorts = [gatewayPort, dashboardPort, vllmPort].map(Number); return ( validReceiptPort(gatewayPort) && validReceiptPort(dashboardPort) && validReceiptPort(vllmPort) && - new Set([gatewayPort, dashboardPort, vllmPort]).size === 3 + new Set(numericPorts).size === 3 ); } diff --git a/test/install-express-prompt.test.ts b/test/install-express-prompt.test.ts index af5ed1a57a..d1c95a1097 100644 --- a/test/install-express-prompt.test.ts +++ b/test/install-express-prompt.test.ts @@ -561,6 +561,25 @@ ensure_station_express_host expect(output).not.toContain("HOST_PREPARATION_INVOKED"); }); + it("rejects numerically equivalent Station Express ports before host preparation (#7203)", () => { + const { result, output } = runInstallerSourced(` +_SELECTED_EXPRESS_PLATFORM='DGX Station' +NEMOCLAW_VLLM_MODEL='deepseek-v4-flash' +NEMOCLAW_GATEWAY_PORT='18081' +NEMOCLAW_DASHBOARD_PORT='018000' +NEMOCLAW_VLLM_PORT='18000' +station_installer_revision() { printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; } +station_express_resume_generation() { printf '0123456789abcdef0123456789abcdef'; } +classify_dgx_station_release() { printf 'supported-ai-developer-tools'; } +run_station_host_preparation() { printf 'HOST_PREPARATION_INVOKED\n'; } +ensure_station_express_host +`); + + expect(result.status, output).not.toBe(0); + expect(output).toContain("NEMOCLAW_DASHBOARD_PORT conflicts with NEMOCLAW_VLLM_PORT (18000)"); + expect(output).not.toContain("HOST_PREPARATION_INVOKED"); + }); + it("restores custom Station Express ports from the accepted receipt without another prompt (#7203)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-port-resume-")); const revision = "a".repeat(40); diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index cad80949b9..a4f9280645 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -137,7 +137,25 @@ printf 'agent=%s\n' "$(resolve_onboarded_agent)"`, } }); + it("normalizes a leading-zero gateway port before selecting its state root", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-normalized-port-")); + try { + const result = runInstallerFunctions( + home, + `NEMOCLAW_GATEWAY_PORT=09123 +printf 'state=%s\n' "$(nemoclaw_state_dir)"`, + ); + + expect(result.status, result.output).toBe(0); + expect(result.output).toContain(`state=${home}/.nemoclaw/gateways/9123`); + expect(result.output).not.toContain("gateways/09123"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it.each([ + "08000", "11434", "18790", ])("rejects conflicting gateway port %s before writing selected state", (gatewayPort) => { From f3b9b1184e5be687f6f80dc2e7a5b28330f2360c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 19 Jul 2026 09:08:22 -0700 Subject: [PATCH 3/3] fix(installer): reject oversized gateway ports Signed-off-by: Carlos Villela --- scripts/install.sh | 7 +++++-- test/install-gateway-state-root.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 26414f9839..4269fbf6dc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -197,10 +197,13 @@ resolve_nemoclaw_gateway_port() { local port="${NEMOCLAW_GATEWAY_PORT:-8080}" port="${port#"${port%%[![:space:]]*}"}" port="${port%"${port##*[![:space:]]}"}" - if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then + if [[ ! "$port" =~ ^0*([0-9]{1,5})$ ]]; then + error "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535." + fi + port="$((10#${BASH_REMATCH[1]}))" + if [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then error "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535." fi - port="$((10#$port))" if [ "$port" -ge 18789 ] && [ "$port" -le 18799 ]; then error "NEMOCLAW_GATEWAY_PORT must not overlap the 18789-18799 dashboard port range." fi diff --git a/test/install-gateway-state-root.test.ts b/test/install-gateway-state-root.test.ts index a4f9280645..5ad91c9488 100644 --- a/test/install-gateway-state-root.test.ts +++ b/test/install-gateway-state-root.test.ts @@ -154,6 +154,25 @@ printf 'state=%s\n' "$(nemoclaw_state_dir)"`, } }); + it("rejects an overlong digit-only gateway port before selecting its state root (#7203)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-overlong-port-")); + try { + const result = runInstallerFunctions( + home, + `NEMOCLAW_GATEWAY_PORT=9999999999999999999999999999999999999999 +nemoclaw_state_dir`, + ); + + expect(result.status, result.output).not.toBe(0); + expect(result.output).toContain( + "NEMOCLAW_GATEWAY_PORT must be an integer between 1024 and 65535", + ); + expect(result.output).not.toContain("integer expression expected"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it.each([ "08000", "11434",