import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureSymlink, prepareManagedCodexHome } from "./codex-home.js"; describe("codex managed home", () => { afterEach(() => { vi.restoreAllMocks(); }); it("treats a concurrently-created expected auth symlink as success", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); const sharedCodexHome = path.join(root, "shared-codex-home"); const paperclipHome = path.join(root, "paperclip-home"); const managedCodexHome = path.join( paperclipHome, "instances", "default", "companies", "company-1", "codex-home", ); const sharedAuth = path.join(sharedCodexHome, "auth.json"); const managedAuth = path.join(managedCodexHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); const originalSymlink = fs.symlink.bind(fs); vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => { await originalSymlink(source, target, type); const error = new Error("file already exists") as NodeJS.ErrnoException; error.code = "EEXIST"; throw error; }); try { await expect( prepareManagedCodexHome( { CODEX_HOME: sharedCodexHome, PAPERCLIP_HOME: paperclipHome, PAPERCLIP_INSTANCE_ID: "default", }, async () => {}, "company-1", ), ).resolves.toBe(managedCodexHome); expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth)); } finally { await fs.rm(root, { recursive: true, force: true }); } }); // Regression for #5028: older Paperclip versions copied auth.json into the // managed home instead of symlinking. After upgrading to the symlink-based // logic, the stale regular file at the target stayed in place and every // subsequent codex_local run failed with refresh_token_reused as soon as the // source token rotated. `ensureSymlink` now heals the upgrade path by // unlinking the stale copy and creating a symlink to the live source. it("replaces a stale regular-file auth.json with a symlink to the live source (#5028)", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); try { const sharedCodexHome = path.join(root, "shared-codex-home"); const paperclipHome = path.join(root, "paperclip-home"); const managedCodexHome = path.join( paperclipHome, "instances", "default", "companies", "company-1", "codex-home", ); const sharedAuth = path.join(sharedCodexHome, "auth.json"); const managedAuth = path.join(managedCodexHome, "auth.json"); await fs.mkdir(sharedCodexHome, { recursive: true }); // The live source has rotated since the stale copy was written. await fs.writeFile(sharedAuth, '{"token":"fresh"}', "utf8"); // Simulate a stale copy left by a previous Paperclip version. await fs.mkdir(managedCodexHome, { recursive: true }); await fs.writeFile(managedAuth, '{"token":"stale-from-copy"}', "utf8"); await prepareManagedCodexHome( { CODEX_HOME: sharedCodexHome, PAPERCLIP_HOME: paperclipHome, PAPERCLIP_INSTANCE_ID: "default", }, async () => {}, "company-1", ); expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); expect(await fs.readFile(managedAuth, "utf8")).toBe('{"token":"fresh"}'); } finally { await fs.rm(root, { recursive: true, force: true }); } }); // Direct unit coverage for the new ensureSymlink branch (#5028). The // regression test above goes through prepareManagedCodexHome, whose // pre-existing apikey-mode cleanup `fs.rm`s the stale auth.json before // ensureSymlink runs — so the heal branch never executes there. Call // ensureSymlink directly to prove the unlink-and-recreate path itself. it("ensureSymlink: unlinks a stale regular file and recreates the symlink", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ensure-symlink-")); try { const source = path.join(root, "live-source.json"); const target = path.join(root, "stale-target.json"); await fs.writeFile(source, '{"token":"fresh"}', "utf8"); await fs.writeFile(target, '{"token":"stale-from-copy"}', "utf8"); await ensureSymlink(target, source); expect((await fs.lstat(target)).isSymbolicLink()).toBe(true); expect(await fs.readFile(target, "utf8")).toBe('{"token":"fresh"}'); } finally { await fs.rm(root, { recursive: true, force: true }); } }); // The isDirectory() guard added with the heal branch must keep an unexpected // directory in place rather than throwing EISDIR. We treat a directory at // this path as operator-owned, not a stale Paperclip copy. it("ensureSymlink: leaves an unexpected directory in place instead of throwing", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ensure-symlink-dir-")); try { const source = path.join(root, "live-source.json"); const target = path.join(root, "unexpected-dir"); await fs.writeFile(source, '{"token":"fresh"}', "utf8"); await fs.mkdir(target); await fs.writeFile(path.join(target, "sentinel"), "keep-me", "utf8"); await expect(ensureSymlink(target, source)).resolves.toBeUndefined(); expect((await fs.lstat(target)).isDirectory()).toBe(true); expect(await fs.readFile(path.join(target, "sentinel"), "utf8")).toBe("keep-me"); } finally { await fs.rm(root, { recursive: true, force: true }); } }); });