diff --git a/src/lib/state/sandbox-manifest-publish.test.ts b/src/lib/state/sandbox-manifest-publish.test.ts new file mode 100644 index 00000000000..4e085f0e5d7 --- /dev/null +++ b/src/lib/state/sandbox-manifest-publish.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { __test, type RebuildManifest } from "./sandbox.js"; + +const tempDirs: string[] = []; + +function manifest(backupPath: string): RebuildManifest { + return { + version: 1, + sandboxName: "alpha", + timestamp: "2026-07-27T21-00-00-000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + failedBackupDirs: [], + stateFiles: [], + dir: "/sandbox", + backupPath, + blueprintDigest: "digest", + policyPresets: [], + customPolicies: [], + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("rebuild manifest publication", () => { + it("publishes a complete private manifest with no visible temporary file", () => { + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-manifest-")); + tempDirs.push(backupPath); + + const expected = manifest(backupPath); + __test.writeManifest(backupPath, expected); + + const manifestPath = path.join(backupPath, "rebuild-manifest.json"); + expect(JSON.parse(fs.readFileSync(manifestPath, "utf8"))).toEqual(expected); + expect(fs.statSync(manifestPath).mode & 0o777).toBe(0o600); + expect(fs.readdirSync(backupPath)).toEqual(["rebuild-manifest.json"]); + }); + + it("removes the unpublished temporary manifest when rename fails", () => { + const remove = vi.fn(); + const rename = vi.fn(() => { + throw new Error("rename failed"); + }); + + expect(() => + __test.writeManifest("/backup", manifest("/backup"), { + write: vi.fn(), + rename, + remove, + }), + ).toThrow("rename failed"); + + const tempPath = path.join("/backup", `.rebuild-manifest.json.tmp.${String(process.pid)}`); + expect(rename).toHaveBeenCalledWith(tempPath, path.join("/backup", "rebuild-manifest.json")); + expect(remove).toHaveBeenCalledWith(tempPath, { force: true }); + }); + + it("preserves the publish failure when temporary cleanup also fails", () => { + expect(() => + __test.writeManifest("/backup", manifest("/backup"), { + write: vi.fn(() => { + throw new Error("write failed"); + }), + rename: vi.fn(), + remove: vi.fn(() => { + throw new Error("cleanup failed"); + }), + }), + ).toThrow("write failed"); + }); +}); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index af6040273a7..192c2268140 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -21,6 +21,7 @@ import { readdirSync, readFileSync, readlinkSync, + renameSync, rmSync, statSync, writeFileSync, @@ -1787,12 +1788,45 @@ function restoreSandboxStateInternal( // ── Manifest ─────────────────────────────────────────────────────── -function writeManifest(backupPath: string, manifest: RebuildManifest): void { +type ManifestPublishOps = { + write(filePath: string, contents: string, options: { mode: number; flag: "wx" }): void; + rename(source: string, destination: string): void; + remove(filePath: string, options: { force: true }): void; +}; + +const manifestPublishOps: ManifestPublishOps = { + write: (filePath, contents, options) => writeFileSync(filePath, contents, options), + rename: (source, destination) => renameSync(source, destination), + remove: (filePath, options) => rmSync(filePath, options), +}; + +function writeManifest( + backupPath: string, + manifest: RebuildManifest, + ops: ManifestPublishOps = manifestPublishOps, +): void { const manifestPath = path.join(backupPath, "rebuild-manifest.json"); - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - chmodSync(manifestPath, 0o600); + const tempPath = path.join(backupPath, `.rebuild-manifest.json.tmp.${String(process.pid)}`); + let published = false; + try { + // A snapshot becomes recoverable only after its complete, private manifest + // is atomically renamed into place. + ops.write(tempPath, JSON.stringify(manifest, null, 2), { mode: 0o600, flag: "wx" }); + ops.rename(tempPath, manifestPath); + published = true; + } finally { + if (!published) { + try { + ops.remove(tempPath, { force: true }); + } catch { + // Preserve the publish failure; a same-directory temp file is never a snapshot. + } + } + } } +export const __test = { writeManifest }; + function readManifestPayload(backupPath: string): unknown | null { const manifestPath = path.join(backupPath, "rebuild-manifest.json"); if (!existsSync(manifestPath)) return null;