paperclip/packages/adapters/codex-local/src/server/codex-home.test.ts

149 lines
5.9 KiB
TypeScript

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 });
}
});
});