diff --git a/server/src/__tests__/remove-test-home.test.ts b/server/src/__tests__/remove-test-home.test.ts new file mode 100644 index 0000000000..4eb8cc182d --- /dev/null +++ b/server/src/__tests__/remove-test-home.test.ts @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { removeTestHome } from "./remove-test-home.js"; + +const homes: string[] = []; + +function makeHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "remove-test-home-")); + homes.push(home); + return home; +} + +/** + * The tree a native session leaves behind: a runtime-context bundle whose + * files are read-only and whose directories are read-only too, the way + * `makeDirectoriesReadOnly` publishes it under the instance root. + */ +function leaveReadOnlyBundle(home: string): string { + const bundle = path.join(home, "instances", "default", "runtime-context-assets", "bundles", "digest"); + const nested = path.join(bundle, "skills", "assigned"); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(bundle, "AGENTS.md"), "# instructions\n", { mode: 0o444 }); + fs.writeFileSync(path.join(nested, "SKILL.md"), "# skill\n", { mode: 0o444 }); + fs.chmodSync(nested, 0o555); + fs.chmodSync(path.dirname(nested), 0o555); + fs.chmodSync(bundle, 0o555); + return bundle; +} + +afterEach(() => { + for (const home of homes.splice(0)) { + if (fs.existsSync(home)) { + // Leave nothing behind even if an assertion failed partway. + fs.chmodSync(home, 0o700); + removeTestHome(home); + } + } +}); + +describe("removeTestHome", () => { + it("removes a home whose runtime-context bundle directories are read-only", () => { + const home = makeHome(); + const bundle = leaveReadOnlyBundle(home); + fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); + + // The plain removal the setup file used to run is what fails in CI. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + expect(() => fs.rmSync(home, { recursive: true, force: true })).toThrow(/EACCES|EPERM/); + expect(fs.existsSync(bundle)).toBe(true); + } + + removeTestHome(home); + expect(fs.existsSync(home)).toBe(false); + }); + + it("removes an ordinary home and tolerates one that is already gone", () => { + const home = makeHome(); + fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true }); + fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); + removeTestHome(home); + expect(fs.existsSync(home)).toBe(false); + expect(() => removeTestHome(home)).not.toThrow(); + }); + + // Windows keeps no Unix mode bits (chmod only toggles the read-only + // attribute, so the 0o555 assertion cannot hold) and a directory symlink + // can need elevated privileges there. POSIX-only, like the suite's other + // permission tests. + it.skipIf(process.platform === "win32")("does not follow a symlink out of the home while making directories writable", () => { + const home = makeHome(); + const outside = makeHome(); + const guarded = path.join(outside, "guarded"); + fs.mkdirSync(guarded); + fs.writeFileSync(path.join(guarded, "keep.txt"), "keep\n"); + fs.chmodSync(guarded, 0o555); + leaveReadOnlyBundle(home); + fs.symlinkSync(outside, path.join(home, "link-out"), "dir"); + + removeTestHome(home); + expect(fs.existsSync(home)).toBe(false); + expect(fs.existsSync(path.join(guarded, "keep.txt"))).toBe(true); + expect(fs.statSync(guarded).mode & 0o777).toBe(0o555); + fs.chmodSync(guarded, 0o700); + }); +}); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts new file mode 100644 index 0000000000..524b1e34f6 --- /dev/null +++ b/server/src/__tests__/remove-test-home.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Removes a per-worker test home at worker exit. + * + * A plain recursive `rmSync` is not enough: a native session materializes its + * runtime-context bundles under the instance root and makes every directory + * in them read-only (`makeDirectoriesReadOnly` in + * services/native-runtime/runtime-context.ts), and a run that is still + * finishing when the worker exits can leave such a bundle under the worker's + * home. Removing a file inside a read-only directory fails with `EACCES`, + * Vitest reports it as an unhandled error, and the shard fails after every + * test passed. So on a permission error every directory in the tree is made + * writable first and the removal is tried once more. + */ +export function removeTestHome(home: string): void { + const options = { recursive: true, force: true, maxRetries: 8, retryDelay: 50 } as const; + try { + fs.rmSync(home, options); + } catch (error) { + if (!isPermissionError(error)) throw error; + makeDirectoriesWritable(home); + fs.rmSync(home, options); + } +} + +function isPermissionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === "EACCES" || code === "EPERM"; +} + +function makeDirectoriesWritable(directory: string): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch { + return; + } + if (!stat.isDirectory()) return; + // Owner read, write and search: enough to list and unlink the children. + // A symlink is not followed, so nothing outside the home is touched. + try { + fs.chmodSync(directory, stat.mode | 0o700); + } catch { + // A directory that cannot be changed still fails the removal below, loudly. + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) makeDirectoriesWritable(path.join(directory, entry.name)); + } +} diff --git a/server/src/__tests__/setup-supertest.ts b/server/src/__tests__/setup-supertest.ts index 9c7db2179d..90a8d75a3c 100644 --- a/server/src/__tests__/setup-supertest.ts +++ b/server/src/__tests__/setup-supertest.ts @@ -5,6 +5,8 @@ import os from "node:os"; import path from "node:path"; import { Server as TlsServer } from "node:tls"; +import { removeTestHome } from "./remove-test-home.js"; + type SupertestServer = NetServer & { address(): ReturnType; listen(port: number): NetServer; @@ -29,7 +31,9 @@ const paperclipTestHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vites process.env.PAPERCLIP_HOME = paperclipTestHome; // Setup-file afterAll hooks run before suites drain asynchronous heartbeats. // Cleanup at worker exit, after all suite teardown, avoids deleting live state. -process.once("exit", () => fs.rmSync(paperclipTestHome, { recursive: true, force: true, maxRetries: 8, retryDelay: 50 })); +// A run still finishing at exit can leave a read-only runtime-context bundle +// under this home, which a plain recursive rm cannot remove. +process.once("exit", () => removeTestHome(paperclipTestHome)); const require = createRequire(import.meta.url); const SupertestTest = require("supertest/lib/test.js") as SupertestTestConstructor;