Files
PaperClipAI/packages/adapters/codex-local/src/server/codex-home.test.ts
T
Devin Foley bce976d60d feat: bind an agent to a Codex login whose account differs from the company default (#13067)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `codex_local` adapter signs agents in to OpenAI, and a company
keeps one default Codex identity in its shared company home
> - A login with a DIFFERENT account than the company default is
deliberately kept out of the shared home — one agent's sign-in must not
switch every unbound agent's credentials — but that left the
cross-account login inert: nothing connected the agent the operator was
configuring to the credential the login stored
> - The stored credential and its company secret already exist; only the
last mile — an agent actually using them — was missing
> - This pull request reports a non-secret binding claim on the
authenticated login and lets the agent page bind that one agent's
`CODEX_HOME` to the account's secret, exactly and only when the
identities differ
> - The benefit is that multi-account Codex becomes one click on the
agent that needs it, with company-wide identity untouched

## Linked Issues or Issue Description

**What happened?**

On an agent's detail page, "Sign in with Codex" using a different OpenAI
account than the company default succeeds but changes nothing for that
agent. The credential lands in the per-identity store and a company
secret names it, but the agent keeps using the company default. The Test
keeps reporting that authentication is needed, and no repeat login
helps.

**Expected behavior**

When the operator deliberately signs an agent's page in with a different
account, that agent starts using that account. Agents that were not part
of the action keep the company default. A same-account login keeps
working through the shared company home with no per-agent pinning.

**Steps to reproduce**

1. Configure a company whose Codex home holds account A.
2. Open a `codex_local` agent's detail page with a sandbox environment
and complete "Sign in with Codex" using account B.
3. Press Test. Before this change the agent still resolves account A and
the authentication-needed check returns.

## What Changed

- `packages/adapters/codex-local` — the prerequisite shield:
`isCodexAuthCachePath` recognizes per-identity credential-store entries,
and `seedManagedCodexHome` refuses to symlink, heal, or
API-key-overwrite an entry's `auth.json`. The seeding pass runs before
every probe and execute; without the shield, an agent bound to an entry
would have its stored login silently swapped for the host credential.
Static shared config files still copy in. Rotation already survives
binding: the sandbox copy-back writes rotated credentials into the
identity-keyed store slot.
- `server` — the promotion records whether the company default home
ended on a different account than the login (any read failure degrades
to `false`, so the client can never be told to bind wrongly). After the
terminal commit, the routes layer remembers a non-secret claim — the
opaque account-home secret id plus that verdict — in a bounded in-memory
map, and merges it into the owner read of an `authenticated`
`codex_local` session. A restart drops the claim; the panel then shows
plain success.
- `packages/shared` — `CodexAccountBindingClaim` on the owner session
response. It carries no account identifier and no credential byte.
- `ui` — the login panel reports the claim upward once. The edit-mode
form binds the agent's `CODEX_HOME` to the secret and saves in one step,
only when `companyIdentityDiffers` is true. Same-account logins bind
nothing on purpose: the company-home refresh already carried them, and
an unbound agent keeps following the company default across rotations.
Create mode is unchanged.

## Verification

- Adapter suite: 381 passed, 1 skipped (includes the new store-entry
shield tests and the path-predicate cases).
- Server suites (8 files): 130 passed, 15 skipped — including two new
route tests that drive a login to `authenticated` and assert the claim
with both identity verdicts.
- UI render suite: 85 passed — including a panel test that the claim is
reported upward exactly once.
- `tsc --noEmit` clean in `packages/shared`, the adapter package, and
`ui`; `server` clean for the touched file.

## Risks

- The bind changes one agent's configuration through the normal
agent-update patch, initiated by the operator's own login on that
agent's page. The failure direction of every fallback is "offer
nothing": a missing claim, a restart, or an unreadable company home all
degrade to no bind.
- The seed shield narrows what the seeding pass may touch; homes outside
the credential store behave exactly as before, covered by the existing
seed tests.
- Builds on the sign-in credential-resolution fix (#13064), now merged;
this branch is rebased onto master and the diff contains only the
binding feature. Supersedes #13066, which GitHub auto-closed when its
stacked base branch was deleted on merge.

## Model Used

Claude (Anthropic) — Claude Fable 5 (`claude-fable-5`), extended
thinking, agentic tool use in Claude Code (terminal).

**Related PRs (searched; no duplicates found):** #12740, #12082, and
#9621 touch adjacent Codex credential sync paths; #8495 is the standing
hardening effort for probe auth seeding.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
standalone docs cover this flow; the behavioral contracts are documented
in-line at each changed site)
- [x] I have considered and documented any risks above
2026-09-10 07:15:01 -07:00

1413 lines
59 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 {
CODEX_SYNC_ALLOWLIST,
codexHomeHasUsableAuth,
ensureSymlink,
evaluateCodexCredentialReadiness,
mergeManagedCodexMcpGateways,
isManagedCodexHomePath,
prepareManagedCodexHome,
reconcileManagedCodexHome,
seedManagedCodexHome,
stageCodexHomeForSync,
writeManagedCodexMcpConfig,
} from "./codex-home.js";
describe("mergeManagedCodexMcpGateways", () => {
it("keeps runtime gateways and appends non-overlapping context gateways", () => {
expect(
mergeManagedCodexMcpGateways(
[{ name: "runtime", endpointPath: "/runtime", bearerToken: "runtime-token" }],
[
{ name: "runtime", endpointPath: "/stale", bearerToken: "stale-token" },
{ name: "manual", endpointPath: "/manual", bearerToken: "manual-token" },
],
),
).toEqual([
{ name: "runtime", endpointPath: "/runtime", bearerToken: "runtime-token" },
{ name: "manual", endpointPath: "/manual", bearerToken: "manual-token" },
]);
});
});
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, '{"OPENAI_API_KEY":"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 });
}
});
it("still throws on EEXIST when a raced-in auth symlink points elsewhere", 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 wrongAuth = path.join(sharedCodexHome, "other-auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}\n', "utf8");
await fs.writeFile(wrongAuth, '{"token":"other"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (_source, target, type) => {
await originalSymlink(wrongAuth, 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",
),
).rejects.toMatchObject({ code: "EEXIST" });
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.readlink(managedAuth)).toBe(wrongAuth);
} 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 });
}
});
});
describe("isManagedCodexHomePath", () => {
const env = {
PAPERCLIP_HOME: "/srv/paperclip",
PAPERCLIP_INSTANCE_ID: "default",
} satisfies NodeJS.ProcessEnv;
const companyRoot = path.resolve(
"/srv/paperclip/instances/default/companies/company-1",
);
it("treats the per-agent managed home as managed", () => {
expect(
isManagedCodexHomePath(
env,
"company-1",
path.join(companyRoot, "agents", "agent-7", "codex-home"),
),
).toBe(true);
});
it("treats the shared company home as managed", () => {
expect(
isManagedCodexHomePath(env, "company-1", path.join(companyRoot, "codex-home")),
).toBe(true);
});
it("treats a path outside the company tree as an external override", () => {
expect(isManagedCodexHomePath(env, "company-1", "/home/dev/.codex")).toBe(false);
expect(
isManagedCodexHomePath(
env,
"company-1",
path.resolve("/srv/paperclip/instances/default/companies/company-2/codex-home"),
),
).toBe(false);
});
it("returns false without a companyId", () => {
expect(isManagedCodexHomePath(env, undefined, path.join(companyRoot, "codex-home"))).toBe(
false,
);
});
});
describe("codexHomeHasUsableAuth", () => {
it("is true for credential-bearing auth.json and false when missing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-"));
try {
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), "{}", "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), '{"foo":"bar"}', "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), '{"token":"shared"}', "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), '{"access_token":"shared"}', "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), '{"OPENAI_API_KEY":"shared"}', "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("recognizes the Codex 0.143 AuthDotJson subscription shape", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-"));
try {
await fs.writeFile(
path.join(root, "auth.json"),
JSON.stringify({
tokens: {
id_token: "synthetic-id-token",
access_token: "synthetic-access-token",
refresh_token: "synthetic-refresh-token",
account_id: "acct-modern",
},
last_refresh: "2026-07-09T00:00:00Z",
}),
"utf8",
);
expect(await codexHomeHasUsableAuth(root)).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("treats subscription auth without account_id or token material as unusable", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-invalid-"));
try {
await fs.writeFile(
path.join(root, "auth.json"),
JSON.stringify({
tokens: {
id_token: "synthetic-id-token",
access_token: "synthetic-access-token",
refresh_token: "synthetic-refresh-token",
},
last_refresh: "2026-07-09T00:00:00Z",
}),
"utf8",
);
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(
path.join(root, "auth.json"),
JSON.stringify({
tokens: {
account_id: "acct-modern",
},
last_refresh: "2026-07-09T00:00:00Z",
}),
"utf8",
);
expect(await codexHomeHasUsableAuth(root)).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("is false for a dangling auth.json symlink", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-dangling-"));
try {
await fs.symlink(path.join(root, "missing-source.json"), path.join(root, "auth.json"));
expect(await codexHomeHasUsableAuth(root)).toBe(false);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("seedManagedCodexHome", () => {
it("symlinks auth.json from the shared source into an explicit per-agent home", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-"));
try {
const sharedCodexHome = path.join(root, "shared-codex-home");
const agentHome = path.join(
root,
"instances",
"default",
"companies",
"company-1",
"agents",
"agent-7",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const agentAuth = path.join(agentHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8");
await seedManagedCodexHome(agentHome, { CODEX_HOME: sharedCodexHome }, async () => {});
expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(agentAuth)).toBe(await fs.realpath(sharedAuth));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("writes an API-key auth.json into the home when an apiKey is supplied", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-"));
try {
const agentHome = path.join(root, "agent-home");
const emptyShared = path.join(root, "empty-shared");
await fs.mkdir(emptyShared, { recursive: true });
await seedManagedCodexHome(agentHome, { CODEX_HOME: emptyShared }, async () => {}, {
apiKey: "sk-test-123",
});
const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"));
expect(written).toEqual({ OPENAI_API_KEY: "sk-test-123" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
// A device-login promotion writes the company credential as a regular-file
// subscription auth.json. Re-seeding must keep it, or the first Test probe or
// run after a successful login silently signs the company out. The cases
// below pin the identity- and freshness-anchored rule: keep a subscription
// identity the shared source does not hold; keep a same-identity file the
// shared source is not strictly fresher than (ties and unparseable freshness
// included); still heal the same-identity stale copy once the shared source
// has moved past it (#5028) and still remove apikey-mode residue.
const subscriptionAuth = (
accountId: string,
marker: string,
lastRefresh: string | null = "2026-07-09T00:00:00Z",
) =>
JSON.stringify({
tokens: {
id_token: `synthetic-id-token-${marker}`,
access_token: `synthetic-access-token-${marker}`,
refresh_token: `synthetic-refresh-token-${marker}`,
account_id: accountId,
},
...(lastRefresh ? { last_refresh: lastRefresh } : {}),
});
it("keeps a promoted subscription auth.json when the shared source has no auth", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-promoted-"));
try {
const companyHome = path.join(root, "company-home");
const emptyShared = path.join(root, "empty-shared");
const promoted = subscriptionAuth("acct-promoted", "promoted");
await fs.mkdir(emptyShared, { recursive: true });
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
await seedManagedCodexHome(companyHome, { CODEX_HOME: emptyShared }, async () => {});
const kept = path.join(companyHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(promoted);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps a promoted subscription auth.json whose identity differs from the shared source", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-foreign-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
const promoted = subscriptionAuth("acct-promoted", "promoted");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(
path.join(sharedCodexHome, "auth.json"),
subscriptionAuth("acct-host", "host"),
"utf8",
);
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const kept = path.join(companyHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(promoted);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("still replaces a same-identity stale regular copy with the shared symlink once the source is strictly fresher (#5028)", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-stale-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
// The live source has rotated since the stale copy was written, so its
// last_refresh is strictly greater — the real #5028 shape.
const fresh = subscriptionAuth("acct-same", "fresh", "2026-07-09T02:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), fresh, "utf8");
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(
path.join(companyHome, "auth.json"),
subscriptionAuth("acct-same", "stale", "2026-07-09T01:00:00Z"),
"utf8",
);
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const healed = path.join(companyHome, "auth.json");
expect((await fs.lstat(healed)).isSymbolicLink()).toBe(true);
expect(await fs.readFile(healed, "utf8")).toBe(fresh);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps a same-identity promoted auth.json that is strictly newer than the shared source", async () => {
// The device-login promotion mints a credential whose last_refresh is newer
// than the host copy the user was failing with. Swapping it for the shared
// symlink here would sign the company back in with that failing credential
// right after the login that replaced it.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-newer-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
const promoted = subscriptionAuth("acct-same", "promoted", "2026-07-09T02:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(
path.join(sharedCodexHome, "auth.json"),
subscriptionAuth("acct-same", "host", "2026-07-09T01:00:00Z"),
"utf8",
);
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const kept = path.join(companyHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(promoted);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps a same-identity auth.json when freshness ties or cannot be compared", async () => {
// Ties and unparseable timestamps keep the file: deleting a promoted
// credential is irreversible, while a kept file self-corrects on the next
// seed once the shared source has provably moved past it.
const cases = [
// Tie: same last_refresh on both sides.
{ source: "2026-07-09T01:00:00Z", target: "2026-07-09T01:00:00Z" },
// The shared source carries no parseable last_refresh.
{ source: null, target: "2026-07-09T01:00:00Z" },
// The file carries no parseable last_refresh.
{ source: "2026-07-09T02:00:00Z", target: null },
];
for (const { source, target } of cases) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-tie-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
const file = subscriptionAuth("acct-same", "file", target);
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(
path.join(sharedCodexHome, "auth.json"),
subscriptionAuth("acct-same", "host", source),
"utf8",
);
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(path.join(companyHome, "auth.json"), file, "utf8");
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const kept = path.join(companyHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(file);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
});
it("keeps the usable target when the shared source exists but cannot be read", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-src-err-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
// A directory at the source auth.json path makes the read fail with
// EISDIR — a deterministic present-but-unreadable source. Removal plus
// the existence-only symlink pass would link the home to a source no
// downstream reader can use, so the usable target must survive.
await fs.mkdir(path.join(sharedCodexHome, "auth.json"), { recursive: true });
await fs.mkdir(companyHome, { recursive: true });
const target = subscriptionAuth("acct-unknown-source", "target");
await fs.writeFile(path.join(companyHome, "auth.json"), target, "utf8");
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const kept = path.join(companyHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(target);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("never symlinks or heals a credential-store entry's auth.json, even for a fresher same-identity source", async () => {
// An agent can bind CODEX_HOME to a per-identity store entry through the
// login's account-home secret, and this seeding pass runs before every
// probe and execute. The entry's auth.json is the durable login the
// promotion/vend/copy-back own — a strictly-fresher same-identity shared
// source must NOT trigger the #5028 heal here, or the bound account is
// silently swapped for the host login.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-"));
try {
const sharedCodexHome = path.join(root, "shared-codex-home");
const entryHome = path.join(
root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound",
);
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "default",
};
const stored = subscriptionAuth("acct-same", "stored", "2026-07-09T01:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(
path.join(sharedCodexHome, "auth.json"),
subscriptionAuth("acct-same", "host", "2026-07-09T02:00:00Z"),
"utf8",
);
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8");
await fs.mkdir(entryHome, { recursive: true });
await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
await seedManagedCodexHome(entryHome, env, async () => {});
const kept = path.join(entryHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(stored);
// The static shared config still copies in, so a bound run gets the
// same config a per-agent home gets.
expect(await fs.readFile(path.join(entryHome, "config.toml"), "utf8")).toBe('model = "gpt-5"\n');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("refuses an API-key rewrite of a credential-store entry's auth.json", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-apikey-"));
try {
const sharedCodexHome = path.join(root, "shared-codex-home");
const entryHome = path.join(
root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound",
);
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "default",
};
const stored = subscriptionAuth("acct-bound-id", "stored", "2026-07-09T01:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.mkdir(entryHome, { recursive: true });
await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
const logs: string[] = [];
await seedManagedCodexHome(entryHome, env, async (_stream, line) => {
logs.push(line);
}, { apiKey: "sk-configured" });
expect(await fs.readFile(path.join(entryHome, "auth.json"), "utf8")).toBe(stored);
expect(logs.join("\n")).toContain("Refusing to write an API-key auth.json into credential-store entry");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("still removes an apikey-mode auth.json so the chatgpt-mode symlink is restored", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-residue-"));
try {
const companyHome = path.join(root, "company-home");
const sharedCodexHome = path.join(root, "shared-codex-home");
const shared = subscriptionAuth("acct-host", "host");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), shared, "utf8");
await fs.mkdir(companyHome, { recursive: true });
await fs.writeFile(
path.join(companyHome, "auth.json"),
'{"OPENAI_API_KEY":"stale-key"}',
"utf8",
);
await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => {});
const healed = path.join(companyHome, "auth.json");
expect((await fs.lstat(healed)).isSymbolicLink()).toBe(true);
expect(await fs.readFile(healed, "utf8")).toBe(shared);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
// Startup backfill for already-isolated managed homes.
describe("reconcileManagedCodexHome", () => {
async function makeFixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-reconcile-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const agentHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"agents",
"agent-7",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const agentAuth = path.join(agentHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8");
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
} satisfies NodeJS.ProcessEnv;
return { root, sharedCodexHome, sharedAuth, agentHome, agentAuth, env };
}
it("seeds a previously-stranded managed home and is a no-op on re-run", async () => {
const fx = await makeFixture();
try {
// The isolation guard created the per-agent home with no auth.json.
expect(await codexHomeHasUsableAuth(fx.agentHome)).toBe(false);
const first = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(first.status).toBe("seeded");
expect(first.home).toBe(fx.agentHome);
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
const second = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(second.status).toBe("already_seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("reports source_auth_missing when shared auth is unavailable", async () => {
const fx = await makeFixture();
try {
await fs.rm(fx.sharedAuth, { force: true });
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(result.status).toBe("source_auth_missing");
await expect(fs.lstat(fx.agentAuth)).rejects.toThrow();
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("leaves a genuine external override untouched", async () => {
const fx = await makeFixture();
try {
const external = path.join(fx.root, "user-codex");
await fs.mkdir(external, { recursive: true });
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: external,
env: fx.env,
});
expect(result.status).toBe("external_override");
expect(await codexHomeHasUsableAuth(external)).toBe(false);
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("reports no_managed_home when no CODEX_HOME is configured", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: null,
env: fx.env,
});
expect(result).toEqual({ status: "no_managed_home", home: null });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("preserves an existing API-key auth.json when the key is secret-bound", async () => {
const fx = await makeFixture();
try {
// A prior execute-time run resolved the secret and wrote a regular-file
// auth.json containing the key.
await fs.mkdir(fx.agentHome, { recursive: true });
await fs.writeFile(
fx.agentAuth,
JSON.stringify({ OPENAI_API_KEY: "sk-secret-resolved" }),
{ mode: 0o600 },
);
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKeySecretBound: true,
env: fx.env,
});
expect(result.status).toBe("already_seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(false);
expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
OPENAI_API_KEY: "sk-secret-resolved",
});
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("seeds the shared symlink for a secret-bound key when no auth exists yet", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKeySecretBound: true,
env: fx.env,
});
expect(result.status).toBe("seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("writes an API-key auth.json into a managed home when an apiKey is supplied", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKey: "sk-reconcile-1",
env: fx.env,
});
expect(result.status).toBe("seeded");
const written = JSON.parse(await fs.readFile(fx.agentAuth, "utf8"));
expect(written).toEqual({ OPENAI_API_KEY: "sk-reconcile-1" });
const second = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKey: "sk-reconcile-1",
env: fx.env,
});
expect(second.status).toBe("already_seeded");
expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
OPENAI_API_KEY: "sk-reconcile-1",
});
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
});
describe("evaluateCodexCredentialReadiness", () => {
async function makeFixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-readiness-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const companyRoot = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
);
const managedCompanyHome = path.join(companyRoot, "codex-home");
const managedAgentHome = path.join(companyRoot, "agents", "agent-1", "codex-home");
const env: NodeJS.ProcessEnv = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
};
await fs.mkdir(sharedCodexHome, { recursive: true });
return { root, sharedCodexHome, managedCompanyHome, managedAgentHome, env };
}
async function writeUsableAuth(home: string) {
await fs.mkdir(home, { recursive: true });
await fs.writeFile(path.join(home, "auth.json"), '{"OPENAI_API_KEY":"sk-live"}\n', "utf8");
}
it("flags a managed home with no source auth and empty OPENAI_API_KEY as not ready", async () => {
const fx = await makeFixture();
try {
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: fx.managedAgentHome,
configuredApiKey: "",
});
expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
expect(result.effectiveHome).toBe(path.resolve(fx.managedAgentHome));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("treats a non-empty resolved OPENAI_API_KEY as ready without touching disk", async () => {
const fx = await makeFixture();
try {
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: fx.managedAgentHome,
configuredApiKey: "sk-agent-key",
});
expect(result).toMatchObject({ managed: true, authMode: "api", ready: true });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("is ready when the shared source home carries usable subscription auth", async () => {
const fx = await makeFixture();
try {
await writeUsableAuth(fx.sharedCodexHome);
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: fx.managedAgentHome,
configuredApiKey: "",
});
expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("is ready when the already-seeded effective home carries usable auth", async () => {
const fx = await makeFixture();
try {
await writeUsableAuth(fx.managedAgentHome);
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: fx.managedAgentHome,
configuredApiKey: "",
});
expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("defaults to the managed company home when no CODEX_HOME is configured", async () => {
const fx = await makeFixture();
try {
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: null,
configuredApiKey: "",
});
expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
expect(result.effectiveHome).toBe(path.resolve(fx.managedCompanyHome));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("treats an external/user-supplied CODEX_HOME override as self-managed and ready", async () => {
const fx = await makeFixture();
try {
const externalHome = path.join(fx.root, "user-codex-home");
await fs.mkdir(externalHome, { recursive: true });
const result = await evaluateCodexCredentialReadiness({
env: fx.env,
companyId: "company-1",
configuredCodexHome: externalHome,
configuredApiKey: "",
});
expect(result).toMatchObject({ managed: false, ready: true });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("replaces the managed MCP block and clears stale servers for an empty runtime set", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-mcp-config-"));
try {
const alphaHome = path.join(root, "agent-alpha");
const zeroHome = path.join(root, "agent-zero");
await writeManagedCodexMcpConfig({
codexHome: alphaHome,
apiBaseUrl: "https://paperclip.example",
gateways: [{
name: "alpha",
endpointPath: "https://paperclip.example/api/tool-gateway/gateways/alpha/mcp",
bearerToken: "alpha-token",
}],
});
await writeManagedCodexMcpConfig({
codexHome: zeroHome,
apiBaseUrl: "https://paperclip.example",
gateways: [{
name: "stale",
endpointPath: "/api/tool-gateway/gateways/stale/mcp",
bearerToken: "stale-token",
}],
});
await writeManagedCodexMcpConfig({
codexHome: zeroHome,
apiBaseUrl: "https://paperclip.example",
gateways: [],
});
const alpha = await fs.readFile(path.join(alphaHome, "config.toml"), "utf8");
const zero = await fs.readFile(path.join(zeroHome, "config.toml"), "utf8");
expect(alpha).toContain('[mcp_servers."alpha"]');
expect(alpha).toContain('Authorization = "Bearer alpha-token"');
expect(zero).not.toContain("mcp_servers.");
expect(zero).not.toContain("stale-token");
expect(alphaHome).not.toBe(zeroHome);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("restricts permissions on an existing managed MCP config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-mcp-config-"));
try {
const configPath = path.join(root, "config.toml");
await fs.writeFile(configPath, "model = \"gpt-5\"\n", { mode: 0o644 });
await writeManagedCodexMcpConfig({
codexHome: root,
apiBaseUrl: "https://paperclip.example",
gateways: [],
});
expect((await fs.stat(configPath)).mode & 0o777).toBe(0o600);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("stageCodexHomeForSync", () => {
afterEach(() => {
vi.restoreAllMocks();
});
// Builds a fake managed CODEX_HOME containing the full allowlist (with
// `auth.json` as a symlink into a separate source-bytes file and a populated
// `skills/` symlink, mirroring the real managed home) plus decoy runtime
// state the allowlist must NOT copy.
async function buildFakeHome(root: string): Promise<{ home: string; authBytes: string; skillBytes: string }> {
const home = path.join(root, "codex-home");
const authSource = path.join(root, "shared", "auth.json");
const skillSource = path.join(root, "shared", "skill-src.md");
const authBytes = '{"tokens":{"account_id":"acct","refresh_token":"r"}}\n';
const skillBytes = "# injected skill\n";
await fs.mkdir(path.join(root, "shared"), { recursive: true });
await fs.writeFile(authSource, authBytes, "utf8");
await fs.writeFile(skillSource, skillBytes, "utf8");
await fs.mkdir(home, { recursive: true });
// auth.json is a symlink into the shared source (single-use rotating tokens).
await fs.symlink(authSource, path.join(home, "auth.json"));
await fs.writeFile(path.join(home, "config.toml"), "model_provider = \"paperclip\"\n", "utf8");
await fs.writeFile(path.join(home, "config.json"), "{}\n", "utf8");
await fs.writeFile(path.join(home, "instructions.md"), "hi\n", "utf8");
// skills/ is a directory of symlinks.
await fs.mkdir(path.join(home, "skills"), { recursive: true });
await fs.symlink(skillSource, path.join(home, "skills", "demo.md"));
// Decoys: large runtime state the 4-name denylist missed.
await fs.writeFile(path.join(home, "logs_2.sqlite"), "x", "utf8");
await fs.writeFile(path.join(home, "state_5.sqlite"), "x", "utf8");
await fs.mkdir(path.join(home, "plugins", "cache"), { recursive: true });
await fs.writeFile(path.join(home, "plugins", "cache", "x"), "x", "utf8");
await fs.mkdir(path.join(home, "sessions"), { recursive: true });
await fs.writeFile(path.join(home, "sessions", "y"), "x", "utf8");
await fs.mkdir(path.join(home, "tmp"), { recursive: true });
await fs.symlink("/usr/bin/env", path.join(home, "tmp", "arg0"));
return { home, authBytes, skillBytes };
}
it("stages exactly the allowlist, derefs auth.json to bytes, and excludes decoys", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-"));
let staged: string | null = null;
try {
const { home, authBytes, skillBytes } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-1" });
const entries = (await fs.readdir(staged)).sort();
expect(entries).toEqual([...CODEX_SYNC_ALLOWLIST].sort());
// Decoys must be absent.
for (const decoy of ["logs_2.sqlite", "state_5.sqlite", "plugins", "sessions", "tmp"]) {
expect(entries).not.toContain(decoy);
}
// auth.json is a regular file (symlink dereferenced) whose bytes equal the target.
const stagedAuth = path.join(staged, "auth.json");
expect((await fs.lstat(stagedAuth)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(stagedAuth, "utf8")).toBe(authBytes);
// skills/ copied recursively with the symlink dereferenced to bytes.
const stagedSkill = path.join(staged, "skills", "demo.md");
expect((await fs.lstat(stagedSkill)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(stagedSkill, "utf8")).toBe(skillBytes);
// config.toml (post-rewrite state) carried through.
expect(await fs.readFile(path.join(staged, "config.toml"), "utf8")).toContain("model_provider");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C1 — staged credential file must be mode 0600 (not the world-readable default).
it("writes the staged auth.json with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-mode-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-mode" });
const mode = (await fs.stat(path.join(staged, "auth.json"))).mode & 0o777;
expect(mode).toBe(0o600);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// config.toml carries the managed MCP `Authorization: Bearer …` header and is
// secret-bearing; the staged copy must be 0600, not the world-readable default.
it("writes the staged config.toml (managed MCP bearer header) with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-toml-mode-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
// Mirror the source writer: config.toml holds an MCP gateway bearer token
// and is persisted 0600 on disk.
await fs.writeFile(
path.join(home, "config.toml"),
"[mcp_servers.paperclip]\nheaders = { Authorization = \"Bearer secret-token\" }\n",
{ mode: 0o600 },
);
staged = await stageCodexHomeForSync(home, { runId: "run-toml-mode" });
const mode = (await fs.stat(path.join(staged, "config.toml"))).mode & 0o777;
expect(mode).toBe(0o600);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Least privilege: no staged regular file needs group/other read, so every
// one (config.json, instructions.md — not just credentials) is staged 0600.
it("writes every staged regular file with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-all-mode-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-all-mode" });
for (const entry of ["auth.json", "config.toml", "config.json", "instructions.md"]) {
const mode = (await fs.stat(path.join(staged, entry))).mode & 0o777;
expect(mode, `${entry} should be staged 0600`).toBe(0o600);
}
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C2 — staged dir must be 0700 (mkdtemp guarantees this on POSIX).
it("creates the staged dir with mode 0700", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dir-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-dir" });
const mode = (await fs.stat(staged)).mode & 0o777;
expect(mode).toBe(0o700);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
it("skips absent optional entries without throwing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-absent-"));
let staged: string | null = null;
try {
// Keyring-credential mode: no auth.json, no config.json.
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-absent" });
const entries = await fs.readdir(staged);
expect(entries).toEqual(["config.toml"]);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
it("treats a dangling auth.json symlink as absent (skips it, no throw)", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dangling-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
await fs.symlink(path.join(root, "gone", "auth.json"), path.join(home, "auth.json"));
await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-dangling" });
expect(await fs.readdir(staged)).toEqual(["config.toml"]);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C3 + C4 — an unexpected I/O error must reject (fail-closed, not partial)
// AND remove the temp dir it created (cleanup on the error path).
it("fails closed and removes the temp dir on an unexpected I/O error", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-fail-"));
try {
const { home } = await buildFakeHome(root);
let createdDir: string | null = null;
const realMkdtemp = fs.mkdtemp.bind(fs);
vi.spyOn(fs, "mkdtemp").mockImplementation(async (prefix: string, ...rest: unknown[]) => {
const dir = await (realMkdtemp as typeof fs.mkdtemp)(prefix, ...(rest as []));
createdDir = dir as string;
return dir;
});
vi.spyOn(fs, "readFile").mockRejectedValue(
Object.assign(new Error("boom"), { code: "EACCES" }),
);
await expect(stageCodexHomeForSync(home, { runId: "run-fail" })).rejects.toThrow("boom");
expect(createdDir).not.toBeNull();
// The staged temp dir was cleaned up despite the failure.
await expect(fs.access(createdDir as unknown as string)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
// Circular symlinks inside skills/ must be silently skipped (not throw ELOOP).
// Skill symlinks that point OUTSIDE skills/ are intentional design (Paperclip
// stores skill packages in a shared location) and are dereferenced normally;
// all resulting files land 0600 inside the 0700 staged dir.
it("skips circular skill symlinks (ELOOP) without throwing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-circular-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills"), { recursive: true });
// Self-referential symlink: would loop forever — must be skipped.
const circularLink = path.join(home, "skills", "loop.md");
await fs.symlink(circularLink, circularLink);
// A normal skill file — must still be staged.
await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-circular" });
const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
// Circular link must be absent.
expect(stagedSkillEntries).not.toContain("loop.md");
// Normal skill file must be present.
expect(stagedSkillEntries).toContain("legit.md");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Mode normalization: nested skill files must be staged 0600 regardless of
// their source mode (0644 documents, 0755 scripts, etc.).
it("writes nested skill files with mode 0600 regardless of source mode", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-skill-mode-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills", "my-skill"), { recursive: true });
// Typical source modes: readable doc (0644) and executable script (0755);
// both must land 0600 in the staged dir.
await fs.writeFile(path.join(home, "skills", "my-skill", "SKILL.md"), "# skill\n", { mode: 0o644 });
await fs.writeFile(path.join(home, "skills", "my-skill", "run.sh"), "#!/bin/sh\n", { mode: 0o755 });
staged = await stageCodexHomeForSync(home, { runId: "run-skill-mode" });
for (const rel of ["my-skill/SKILL.md", "my-skill/run.sh"]) {
const mode = (await fs.stat(path.join(staged, "skills", rel))).mode & 0o777;
expect(mode, `skills/${rel} should be staged 0600`).toBe(0o600);
}
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Finding 1 (Greptile): a directory symlink such as `back -> .` inside a skill
// resolves to an ancestor directory (it does NOT raise ELOOP), so naive
// recursion would traverse the same tree forever until disk/memory is
// exhausted. Cycle detection must let staging finish while still copying the
// real content and skipping the self-referential link.
it("does not infinitely traverse an ancestor directory link (back -> .) inside a skill", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-cycle-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
const skillDir = path.join(home, "skills", "my-skill");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
// Directory symlink pointing at the skill's own dir — resolves to a
// directory already on the active traversal path; must be skipped.
await fs.symlink(".", path.join(skillDir, "back"));
staged = await stageCodexHomeForSync(home, { runId: "run-cycle" });
// Completed without hanging; the real file is staged and the cyclic link
// produced no runaway nested `back/back/…` chain (it is skipped entirely).
expect(await fs.readdir(path.join(staged, "skills", "my-skill"))).toEqual(["SKILL.md"]);
expect(await fs.readFile(path.join(staged, "skills", "my-skill", "SKILL.md"), "utf8")).toBe(
"# skill\n",
);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Finding 2 (Greptile): a symlink *inside* a skill that points to a host file
// or directory OUTSIDE that skill (e.g. `~/.ssh/id_rsa`) must NOT be
// dereferenced into the staged asset — otherwise a malformed/compromised skill
// could smuggle host secrets past CODEX_SYNC_ALLOWLIST.
it("does not stage a nested skill symlink that escapes the skill root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-escape-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
const skillDir = path.join(home, "skills", "my-skill");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
// A host "secret" file and directory living OUTSIDE the skill dir.
const secretFile = path.join(root, "host-secret.txt");
await fs.writeFile(secretFile, "TOP SECRET\n", "utf8");
const secretDir = path.join(root, "host-secret-dir");
await fs.mkdir(secretDir, { recursive: true });
await fs.writeFile(path.join(secretDir, "creds"), "creds\n", "utf8");
// Nested symlinks inside the skill escaping to those host paths.
await fs.symlink(secretFile, path.join(skillDir, "stolen.txt"));
await fs.symlink(secretDir, path.join(skillDir, "stolen-dir"));
staged = await stageCodexHomeForSync(home, { runId: "run-escape" });
const stagedEntries = await fs.readdir(path.join(staged, "skills", "my-skill"));
// The legit in-skill file is staged…
expect(stagedEntries).toContain("SKILL.md");
// …but neither escaping link is followed into the staged asset.
expect(stagedEntries).not.toContain("stolen.txt");
expect(stagedEntries).not.toContain("stolen-dir");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Defense-in-depth for finding 1: a degenerate top-level `skills/<x> -> ..`
// link resolves to an ancestor of `skills/` (the home). It must be skipped,
// never adopted as a containment root — otherwise the whole home
// (`sessions/`, `*.sqlite`, …) would be dragged into the staged skills asset.
it("skips a top-level skills entry that resolves to an ancestor of skills/", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-ancestor-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills"), { recursive: true });
await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
// Runtime state in the home that must never reach the staged asset.
await fs.writeFile(path.join(home, "logs.sqlite"), "x", "utf8");
// `up -> ..` resolves to the home dir (an ancestor of skills/).
await fs.symlink("..", path.join(home, "skills", "up"));
staged = await stageCodexHomeForSync(home, { runId: "run-ancestor" });
const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
expect(stagedSkillEntries).toContain("legit.md");
// The ancestor link is skipped, so the home's runtime state is not dragged in.
expect(stagedSkillEntries).not.toContain("up");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
});