From f5dd97901c2d7efe93fad83eeb665844e9ecd92e Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 17 Jul 2026 17:46:10 -0700 Subject: [PATCH] fix(installer): accept current Station resume receipts Signed-off-by: Senthil Ravichandran --- .../onboard/station-express-resume.test.ts | 46 +++++++++++++++++++ src/lib/onboard/station-express-resume.ts | 14 +++++- test/install-station-host-preparation.test.ts | 4 ++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index b28d2a91b9c..88e771379d1 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -52,6 +52,13 @@ function receiptText(generation = receiptGeneration, model = "nemotron-3-ultra-5 return `revision=${receiptRevision}\nmodel=${model}\ngeneration=${generation}\n`; } +function currentReceiptText( + overrides: Partial<{ agent: string; sandbox: string; policyTier: string }> = {}, +): string { + const { agent = "hermes", sandbox = "my-assistant", policyTier = "balanced" } = overrides; + return `${receiptText().trimEnd()}\nagent=${agent}\nsandbox=${sandbox}\npolicy_tier=${policyTier}\n`; +} + function retirementClaims(home: string): string[] { const stateDir = path.join(home, ".nemoclaw"); return fs @@ -620,6 +627,45 @@ describe("DGX Station Express resume (#7048)", () => { } }); + it("accepts and retires the current installer receipt with complete express intent", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-current-receipt-")); + const stateDir = path.join(home, ".nemoclaw"); + const receipt = path.join(stateDir, "station-express-resume"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(receipt, currentReceiptText(), { 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([ + ["agent", { agent: "unknown-agent" }], + ["sandbox", { sandbox: "Invalid Sandbox" }], + ["policy tier", { policyTier: "unrestricted" }], + ])("rejects a current installer receipt with an invalid %s", (_field, overrides) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-current-invalid-")); + const stateDir = path.join(home, ".nemoclaw"); + const receipt = path.join(stateDir, "station-express-resume"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(receipt, currentReceiptText(overrides), { mode: 0o600 }); + + try { + expect(() => + assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), + ).toThrow("installer resume state is malformed"); + expect(fs.readFileSync(receipt, "utf8")).toBe(currentReceiptText(overrides)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it.each([ ["Nemotron Ultra", "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4"], ["DeepSeek V4 Flash", "deepseek-ai/DeepSeek-V4-Flash"], diff --git a/src/lib/onboard/station-express-resume.ts b/src/lib/onboard/station-express-resume.ts index e135bd0c27c..57be2cade6c 100644 --- a/src/lib/onboard/station-express-resume.ts +++ b/src/lib/onboard/station-express-resume.ts @@ -82,6 +82,8 @@ 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_AGENTS = new Set(["openclaw", "hermes", "langchain-deepagents-code"]); +const STATION_EXPRESS_RECEIPT_POLICY_TIERS = new Set(["restricted", "balanced", "open"]); function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -243,7 +245,17 @@ export function assertStationExpressInstallerResumeSafe( function readStationExpressInstallerResumeGeneration(stateFile: string): string { const lines = fs.readFileSync(stateFile, "utf8").split("\n"); - if (lines.length !== 4 || lines[3] !== "") { + const legacyFormat = lines.length === 4 && lines[3] === ""; + const currentFormat = + lines.length === 7 && + lines[6] === "" && + 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)); + if (!legacyFormat && !currentFormat) { 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-host-preparation.test.ts b/test/install-station-host-preparation.test.ts index 8b9827c79f2..dcfa596ba4f 100644 --- a/test/install-station-host-preparation.test.ts +++ b/test/install-station-host-preparation.test.ts @@ -7,6 +7,7 @@ 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"; @@ -1115,6 +1116,9 @@ ensure_station_express_host "agent=openclaw\nsandbox=my-assistant\npolicy_tier=balanced\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}`); });