From ebe1584f29236c77d7c2af4f3eacbdfaca4e39d7 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Tue, 4 Aug 2026 16:16:24 +0800 Subject: [PATCH 1/2] fix(installer): accept the current mode-bound Station Express resume receipt (#8205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/install.sh save_station_express_resume writes a ten-field receipt ending in `mode=`, but readStationExpressInstallerResumeGeneration only accepted the legacy three-field, agent-bound six-field, and port-bound nine-field formats. After a reboot/relogin continuation, completeSession() therefore rejected the installer's own current receipt with "DGX Station Express installer resume state is malformed" — even though the deployment was fully healthy and printed "OpenClaw is ready". Add a mode-bound format branch that accepts the exact ten-field receipt (the nine port-bound fields plus `mode=`, matching validate_station_install_mode in install.sh), while still accepting the legacy formats and rejecting an unknown/expanded mode. Add tests that write the current installer receipt and assert the production parser accepts it (and rejects an unknown mode), coupling the writer and parser contracts so they cannot drift again. Signed-off-by: Jason Ma Co-Authored-By: Claude Opus 4.8 (1M context) --- .../onboard/station-express-resume.test.ts | 40 +++++++++++++++++++ src/lib/onboard/station-express-resume.ts | 27 ++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index 5247a870649..afecbc6ed14 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -79,6 +79,12 @@ function portReceiptText( ); } +// The current installer's ten-field receipt: the nine port-bound fields plus a +// trailing `mode=` field (scripts/install.sh save_station_express_resume). +function modeReceiptText(mode = "express"): string { + return `${portReceiptText().trimEnd()}\nmode=${mode}\n`; +} + function retirementClaims(home: string): string[] { const stateDir = path.join(home, ".nemoclaw"); return fs @@ -645,6 +651,40 @@ describe("DGX Station Express resume (#7048)", () => { } }); + it("accepts the current ten-field mode-bound installer receipt (#8205)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-mode-receipt-")); + const stateDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(path.join(stateDir, "station-express-resume"), modeReceiptText(), { + mode: 0o600, + }); + + try { + expect(() => + assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), + ).not.toThrow(); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + 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"), modeReceiptText("bogus"), { + 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) : ""; From b07ec731de1b16319f431388b4ca7423f5ea279e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 08:18:48 -0400 Subject: [PATCH 2/2] test(installer): cover resume receipt contract Signed-off-by: Julie Yaunches --- .../onboard/station-express-resume.test.ts | 31 ++----- test/install-station-resume-cleanup.test.ts | 88 ++++++++++++++++++- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/src/lib/onboard/station-express-resume.test.ts b/src/lib/onboard/station-express-resume.test.ts index afecbc6ed14..d6b1a0683e0 100644 --- a/src/lib/onboard/station-express-resume.test.ts +++ b/src/lib/onboard/station-express-resume.test.ts @@ -79,12 +79,6 @@ function portReceiptText( ); } -// The current installer's ten-field receipt: the nine port-bound fields plus a -// trailing `mode=` field (scripts/install.sh save_station_express_resume). -function modeReceiptText(mode = "express"): string { - return `${portReceiptText().trimEnd()}\nmode=${mode}\n`; -} - function retirementClaims(home: string): string[] { const stateDir = path.join(home, ".nemoclaw"); return fs @@ -651,30 +645,15 @@ describe("DGX Station Express resume (#7048)", () => { } }); - it("accepts the current ten-field mode-bound installer receipt (#8205)", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-station-mode-receipt-")); - const stateDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(stateDir, { mode: 0o700 }); - fs.writeFileSync(path.join(stateDir, "station-express-resume"), modeReceiptText(), { - mode: 0o600, - }); - - try { - expect(() => - assertStationExpressInstallerResumeMatches(receiptGeneration, { HOME: home }), - ).not.toThrow(); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - 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"), modeReceiptText("bogus"), { - mode: 0o600, - }); + fs.writeFileSync( + path.join(stateDir, "station-express-resume"), + `${portReceiptText().trimEnd()}\nmode=bogus\n`, + { mode: 0o600 }, + ); try { expect(() => 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,