From 33ecdbb3464088bfe877a6e1da20d1e0dc18d0ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 21 Sep 2026 06:02:30 -0700 Subject: [PATCH] fix(test): preserve private temporary fixture ownership (#154759) --- src/test-helpers/temp-dir.test.ts | 48 +++++++++++++++++++++++++++++++ src/test-utils/temp-home.test.ts | 46 +++++++++++++++++++++++++++++ src/test-utils/temp-home.ts | 7 +++-- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/test-helpers/temp-dir.test.ts b/src/test-helpers/temp-dir.test.ts index fb892baa8ae7..88517cf934a1 100644 --- a/src/test-helpers/temp-dir.test.ts +++ b/src/test-helpers/temp-dir.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { execNodeEvalSync } from "../test-utils/node-process.js"; import { withTempDirSync, withTestDir } from "./temp-dir.js"; const parentRoots: string[] = []; @@ -74,3 +75,50 @@ describe("withTestDir", () => { await expect(fs.readdir(parentDir)).resolves.toStrictEqual([]); }); }); + +describe.skipIf(process.platform === "win32")("private temporary case directories", () => { + it.each(["async", "sync", "suite"])( + "admits a private workspace under a group-writable umask (%s)", + async (kind) => { + const parentDir = await makeParentRoot(); + // umask is process-wide and cannot be changed in a Vitest worker thread. + const stdout = execNodeEvalSync( + `import fs from "node:fs"; +import { tempWorkspaceSync } from "@openclaw/fs-safe/temp"; +import { withTestDir, withTempDirSync, createSuiteTempRootTracker } from ${JSON.stringify(new URL("./temp-dir.ts", import.meta.url).href)}; +process.umask(0o002); +const options = { prefix: "private-case-", parentDir: ${JSON.stringify(parentDir)} }; +function exercise(dir) { + const workspace = tempWorkspaceSync({ rootDir: dir, prefix: "private-workspace-" }); + try { + workspace.write("marker", "private"); + return workspace.read("marker").toString(); + } finally { + workspace.cleanup(); + } +} +let result; +if (${JSON.stringify(kind)} === "async") { + result = await withTestDir({ ...options, subdir: "nested/leaf" }, async (dir) => exercise(dir)); +} else if (${JSON.stringify(kind)} === "sync") { + result = withTempDirSync({ ...options, subdir: "nested/leaf" }, exercise); +} else { + const tracker = createSuiteTempRootTracker(options); + await tracker.setup(); + try { + result = exercise(await tracker.make("nested/case")); + } finally { + await tracker.cleanup(); + } +} +console.log(JSON.stringify({ result, remaining: fs.readdirSync(options.parentDir) }));`, + { + imports: [new URL("../../scripts/tsx.mjs", import.meta.url).href], + timeout: 10_000, + killSignal: "SIGKILL", + }, + ); + expect(JSON.parse(stdout)).toEqual({ result: "private", remaining: [] }); + }, + ); +}); diff --git a/src/test-utils/temp-home.test.ts b/src/test-utils/temp-home.test.ts index dad057e68ff1..d8106a230024 100644 --- a/src/test-utils/temp-home.test.ts +++ b/src/test-utils/temp-home.test.ts @@ -7,6 +7,12 @@ import { createDeferred } from "../../test/helpers/promise.js"; import { runExclusiveSessionStoreWrite } from "../config/sessions/store-writer.js"; import { resolveEffectiveHomeDir } from "../infra/home-dir.js"; import { withTempHomeCore } from "../plugin-sdk/test-helpers/temp-home.js"; +import { + captureOpenClawStateDatabaseReadAdmission, + closeOpenClawStateDatabaseByPathAsync, + registerOpenClawStateDatabaseAsyncResource, +} from "../state/openclaw-state-db-cache.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { captureEnv, captureFullEnv, withEnvAsync } from "./env.js"; import { createTempHomeEnv } from "./temp-home.js"; @@ -21,6 +27,46 @@ async function expectPathMissing(targetPath: string): Promise { } describe("createTempHomeEnv", () => { + it("restores the environment and retains home after resource drainage fails", async () => { + const envKeys = [ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + ]; + const environment = captureEnv(envKeys); + const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + const temporary = await createTempHomeEnv("openclaw-temp-home-drain-"); + const stateDir = path.join(temporary.home, ".openclaw"); + const databasePath = resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateDir }); + const admission = captureOpenClawStateDatabaseReadAdmission(databasePath); + const failure = new Error("fixture resource still owns its state"); + let rejectClose = true; + const unregister = registerOpenClawStateDatabaseAsyncResource({ + close: async (identity) => { + if (identity?.key === admission.identity.key && rejectClose) { + throw failure; + } + }, + }); + const marker = path.join(temporary.home, "owned.txt"); + try { + await fs.writeFile(marker, "retained fixture"); + await expect(temporary.restore()).rejects.toBe(failure); + expect(Object.fromEntries(envKeys.map((key) => [key, process.env[key]]))).toEqual(previous); + expect(await fs.readFile(marker, "utf8")).toBe("retained fixture"); + } finally { + // Release the deliberately retained owner before disposing its fixture. + rejectClose = false; + await closeOpenClawStateDatabaseByPathAsync(databasePath); + unregister(); + environment.restore(); + await fs.rm(temporary.home, { recursive: true, force: true }); + } + }); + it.each(["directory", "environment"])( "rolls back failed %s acquisition without removing a sibling home", async (stage) => { diff --git a/src/test-utils/temp-home.ts b/src/test-utils/temp-home.ts index d429800b8419..82cf5142912a 100644 --- a/src/test-utils/temp-home.ts +++ b/src/test-utils/temp-home.ts @@ -77,8 +77,11 @@ export async function createTempHomeEnv(prefix: string): Promise { return { home, restore: async () => { - await cleanupSessionStateForTest({ stateDir }).catch(() => undefined); - snapshot.restore(); + try { + await cleanupSessionStateForTest({ stateDir }); + } finally { + snapshot.restore(); + } await fs.rm(home, { recursive: true, force: true }); }, };