diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index 5247a870649..d6b1a0683e0 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -645,6 +645,25 @@ describe("DGX Station Express resume (#7048)", () => { } }); + it("rejects a mode-bound receipt with an unknown mode value (#8205)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-mode-bad-")); + const stateDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync( + path.join(stateDir, "station-express-resume"), + `${portReceiptText().trimEnd()}\nmode=bogus\n`, + { mode: 0o600 }, + ); + + try { + expect(() => + assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), + ).toThrow("malformed"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("retires only the exact matching installer receipt generation", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-receipt-match-")); 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 85a2198a280..297530447e0 100644 --- a/src/lib/onboard/station-express-resume.ts +++ b/src/lib/onboard/station-express-resume.ts @@ -112,6 +112,8 @@ 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"]); +// Mirrors validate_station_install_mode() in scripts/install.sh. +const STATION_EXPRESS_RECEIPT_MODES = new Set(["express", "provider"]); function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -341,7 +343,30 @@ function readStationExpressInstallerResumeGeneration(stateFile: string): string lines[7].slice("dashboard_port=".length), lines[8].slice("vllm_port=".length), ); - if (!legacyFormat && !currentFormat && !portFormat) { + // The current installer appends a tenth `mode=` field after the nine + // port-bound fields (scripts/install.sh save_station_express_resume). Accept + // that exact shape so completeSession() can validate and retire the receipt + // the same installer wrote, while still rejecting an unknown/expanded mode. + const modeFormat = + lines.length === 11 && + lines[10] === "" && + 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), + ) && + lines[9]?.startsWith("mode=") && + STATION_EXPRESS_RECEIPT_MODES.has(lines[9].slice("mode=".length)); + if (!legacyFormat && !currentFormat && !portFormat && !modeFormat) { 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-station-resume-cleanup.test.ts b/test/install-station-resume-cleanup.test.ts index 4cf42a4cb88..d91ed07ca3f 100644 --- a/test/install-station-resume-cleanup.test.ts +++ b/test/install-station-resume-cleanup.test.ts @@ -8,8 +8,94 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { assertStationExpressInstallerResumeMatches } from "../src/lib/onboard/station-express-resume"; import { INSTALLER_PAYLOAD, TEST_SYSTEM_PATH } from "./helpers/installer-sourced-env"; +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const STATION_REVISION = "a".repeat(40); +const STATION_GENERATION = "0123456789abcdef0123456789abcdef"; + +function writeStationExpressInstallerResume(mode: "express" | "provider") { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-resume-contract-")); + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + ` +source "$INSTALLER_UNDER_TEST" >/dev/null +NEMOCLAW_VLLM_MODEL='nemotron-3-ultra-550b-a55b' +NEMOCLAW_DASHBOARD_PORT='18790' +NEMOCLAW_VLLM_PORT='18000' +_STATION_INSTALL_MODE='${mode}' +station_installer_revision() { printf '${STATION_REVISION}'; } +station_express_resume_generation() { printf '${STATION_GENERATION}'; } +save_station_express_resume +`, + ], + { + cwd: REPO_ROOT, + encoding: "utf8", + env: { + HOME: home, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + PATH: TEST_SYSTEM_PATH, + }, + timeout: 15_000, + killSignal: "SIGKILL", + }, + ); + return { home, result, output: `${result.stdout}${result.stderr}` }; +} + +describe("DGX Station installer resume contract", () => { + it("accepts the express resume receipt written by the installer (#8205)", () => { + const { home, result, output } = writeStationExpressInstallerResume("express"); + + try { + expect(result.status, output).toBe(0); + expect(() => + assertStationExpressInstallerResumeMatches(STATION_GENERATION, { HOME: home }), + ).not.toThrow(); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("accepts the provider resume receipt written by the installer (#8205)", () => { + const { home, result, output } = writeStationExpressInstallerResume("provider"); + + try { + expect(result.status, output).toBe(0); + expect(() => + assertStationExpressInstallerResumeMatches(STATION_GENERATION, { HOME: home }), + ).not.toThrow(); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it("rejects the installer receipt when the writer mode field drifts (#8205)", () => { + const { home, result, output } = writeStationExpressInstallerResume("express"); + const stateFile = path.join(home, ".nemoclaw", "station-express-resume"); + + try { + expect(result.status, output).toBe(0); + const receipt = fs.readFileSync(stateFile, "utf8"); + const driftedReceipt = receipt.replace(/^mode=/m, "install_mode="); + expect(driftedReceipt).not.toBe(receipt); + fs.writeFileSync(stateFile, driftedReceipt, { mode: 0o600 }); + + expect(() => + assertStationExpressInstallerResumeMatches(STATION_GENERATION, { HOME: home }), + ).toThrow("malformed"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("DGX Station installer resume cleanup", () => { it("preserves pair and SSH-binding state when interactive host preflight skips onboarding", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-resume-cleanup-")); @@ -37,7 +123,7 @@ printf 'PAIR=%s BINDING=%s EXPRESS=%s\n' \ `, ], { - cwd: path.resolve(import.meta.dirname, ".."), + cwd: REPO_ROOT, encoding: "utf8", env: { ...process.env,