fix(codex-local): keep a promoted device-login credential when re-seeding the managed home (#11578)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The codex_local adapter supports a device login that runs in a trusted sandbox and promotes the credential into the per-company managed Codex home > - The managed-home re-seeding step treats every regular-file `auth.json` as apikey-mode residue and removes it so the shared-home symlink can be restored > - The promotion writes the company credential as a regular file, so the first environment Test or run after a successful login deletes it > - On a server with no shared Codex login — any containerized deployment — nothing replaces the file, and the UI reports that the sandbox has no ready authentication right after it reported a successful login > - This pull request makes the cleanup identity-anchored: a subscription credential whose identity the shared source does not hold survives re-seeding > - The benefit is that a device login stays usable after Test and runs, on hosts with and without a shared Codex login ## Linked Issues or Issue Description No public GitHub issue covers this. The problem is described in-PR following the bug template. Related public PRs: [#11237](https://github.com/paperclipai/paperclip/pull/11237) added the sandbox device login and the credential promotion, [#11097](https://github.com/paperclipai/paperclip/pull/11097) added its building blocks, and the `ensureSymlink` heal for stale copies came from the fix for #5028. [#9621](https://github.com/paperclipai/paperclip/pull/9621) touches the adjacent sandbox auth sync-back lane but not this defect. **Subsystem affected** packages/adapters/codex-local — managed `CODEX_HOME` seeding (`codex-home.ts`). **Current behavior** A successful device login promotes the subscription `auth.json` into the company Codex home as a regular file, and the UI reports the login as authenticated. The next `seedManagedCodexHome` call — the environment Test probe and every execute both run it — removes any regular-file `auth.json` when no API key is configured, because the cleanup assumes such a file is apikey-mode residue left by a previous run. It then symlinks `auth.json` from the shared source home. On a server whose shared home has no Codex login (a container image, for example), there is no source to symlink, so the home ends with no credential at all. The Test probe then reports "The sandbox has no ready authentication for this adapter" immediately after a successful login, and a fresh login repeats the same cycle. On a server whose shared home does hold a login, the symlink silently replaces the promoted account with the host account. **Expected behavior** The credential a device login promoted stays in the company home across Test probes and runs. The #5028 heal (a stale regular-file copy of the shared credential becomes a symlink to the live source) and the apikey-residue cleanup keep working. **Steps to reproduce** 1. Run the server in an environment whose shared Codex home (`$CODEX_HOME` or `~/.codex`) has no `auth.json`. 2. Complete a Codex device login for a company; the promotion writes the company home `auth.json` and the UI reports authenticated. 3. Click Test on a codex_local agent (or start a run). The probe reports no ready authentication, and the promoted `auth.json` is gone from the company home. **Proposed solution** Make the cleanup identity-anchored, the same rule the promotion and the cache vend already use. A regular-file `auth.json` survives re-seeding when it holds a usable subscription identity that the shared source does not also hold, and the shared symlink does not replace it. A same-identity regular file is still the #5028 stale copy and is still healed into the symlink, because the symlink serves the same account with live, rotating tokens. An apikey-mode or unreadable file is still removed. ## What Changed - `seedManagedCodexHome` reads the target `auth.json` before the cleanup and keeps it when `readSubscriptionAccountId` yields an identity the shared source `auth.json` does not hold. The kept file is excluded from the shared symlink pass, and the function logs a fixed line when it keeps the file. - The function doc comment states the kept-promoted-credential rule. - Four new `seedManagedCodexHome` test cases: a promoted credential with no shared auth, a promoted credential with a different shared identity, the same-identity #5028 heal, and apikey-mode residue removal. ## Verification ```sh cd packages/adapters/codex-local npx tsc --noEmit # clean npx vitest run # 28 files, 321 passed, 1 skipped ``` The four new cases fail on the previous code: the first two observed the promoted file deleted (and, with a shared login present, replaced by the shared symlink). ## Risks Low risk. The change narrows one deletion path. Deployments that never use the device login see no difference: without a promoted subscription file, the cleanup and the symlink behave exactly as before, and the #5028 heal is pinned by an existing test plus a new same-identity test. The one deliberate behavioral shift: after a device login, the promoted company credential now stays authoritative over the shared host login for that company — which is the promotion's documented contract ("the company credential slot"). ## Model Used Claude Fable 5 (`claude-fable-5`), extended thinking, with tool use and code execution — investigation, implementation, and tests. ## 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 - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
adfb357223
commit
48f4ae16ac
|
|
@ -400,6 +400,142 @@ describe("seedManagedCodexHome", () => {
|
|||
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 four cases
|
||||
// below pin the identity-anchored rule: keep a subscription identity the
|
||||
// shared source does not hold; still heal the same-identity stale copy
|
||||
// (#5028) and still remove apikey-mode residue.
|
||||
const subscriptionAuth = (accountId: string, marker: string) =>
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
id_token: `synthetic-id-token-${marker}`,
|
||||
access_token: `synthetic-access-token-${marker}`,
|
||||
refresh_token: `synthetic-refresh-token-${marker}`,
|
||||
account_id: accountId,
|
||||
},
|
||||
last_refresh: "2026-07-09T00:00:00Z",
|
||||
});
|
||||
|
||||
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 (#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");
|
||||
const fresh = subscriptionAuth("acct-same", "fresh");
|
||||
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"),
|
||||
"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 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("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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { readSubscriptionAccountId } from "./codex-auth-cache.js";
|
||||
|
||||
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
||||
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
||||
|
|
@ -572,8 +573,11 @@ export async function stageCodexHomeForSync(
|
|||
* `auth.json` from the shared source home (so ChatGPT-subscription credentials
|
||||
* stay live and single-use refresh tokens are not copied), copies the static
|
||||
* shared config files, and — when an API key is supplied — writes an API-key
|
||||
* `auth.json` instead. Used both for the default company home and for the
|
||||
* per-agent home set by the server isolation guard.
|
||||
* `auth.json` instead. A promoted device-login credential — a regular-file
|
||||
* `auth.json` holding a subscription identity the shared source does not hold —
|
||||
* is kept authoritative: it is neither removed nor replaced by the shared
|
||||
* symlink. Used both for the default company home and for the per-agent home
|
||||
* set by the server isolation guard.
|
||||
*/
|
||||
export async function seedManagedCodexHome(
|
||||
targetHome: string,
|
||||
|
|
@ -588,20 +592,81 @@ export async function seedManagedCodexHome(
|
|||
|
||||
await fs.mkdir(targetHome, { recursive: true });
|
||||
|
||||
// If a previous run wrote an apikey-mode auth.json (regular file) and this
|
||||
// run has no apiKey, remove it so the chatgpt-mode symlink can be restored.
|
||||
// Without this cleanup, ensureSymlink bails on a non-symlink and Codex keeps
|
||||
// authenticating with the stale key after it is removed from configuration.
|
||||
// A regular-file auth.json in the target home is one of two very different
|
||||
// things. The device-login promotion writes the company credential as a
|
||||
// regular file, and that file is the durable outcome of an interactive login,
|
||||
// so it must survive re-seeding. Everything else — an apikey-mode file left by
|
||||
// a previous run, a stale pre-symlink copy of the shared credential (#5028),
|
||||
// or an unreadable payload — is residue, and removing it lets the chatgpt-mode
|
||||
// symlink be restored (ensureSymlink would otherwise replace it and Codex
|
||||
// would keep authenticating with the stale key).
|
||||
//
|
||||
// The discriminator is identity-anchored, like the promotion and the cache
|
||||
// vend: keep the file only when it holds a usable subscription identity that
|
||||
// the shared source does not also hold. A same-identity regular file is the
|
||||
// #5028 stale copy — the symlink serves the same account with live, rotating
|
||||
// tokens, so it is strictly better. A different-identity (or source-less)
|
||||
// subscription file is the promoted company credential; on a server with no
|
||||
// shared login there is nothing to symlink at all, and deleting it would
|
||||
// silently sign the company out right after a successful device login.
|
||||
let keepPromotedAuth = false;
|
||||
if (!apiKey && seedFromShared) {
|
||||
const authPath = path.join(targetHome, "auth.json");
|
||||
const existing = await fs.lstat(authPath).catch(() => null);
|
||||
if (existing && !existing.isSymbolicLink()) {
|
||||
await fs.rm(authPath, { force: true });
|
||||
const targetBytes = await fs.readFile(authPath).catch(() => null);
|
||||
const targetIdentity = targetBytes ? readSubscriptionAccountId(targetBytes) : null;
|
||||
if (targetIdentity) {
|
||||
// Any source read failure — absent or unreadable — keeps the usable
|
||||
// target file. The alternative, removal plus the existence-only
|
||||
// symlink pass below, links the home to a source this process just
|
||||
// failed to read, and every downstream reader (the probe seeding, the
|
||||
// sandbox stage sync, the CLI itself) runs with the same access, so
|
||||
// that home is unusable in every scenario. Keeping the target is
|
||||
// better or equal in each case: a promoted credential keeps working,
|
||||
// and even a stale same-identity copy (#5028) can still work, while
|
||||
// the unreadable symlink cannot. A transient read failure also
|
||||
// self-corrects — the next seed with a readable source heals a
|
||||
// same-identity copy into the symlink — whereas removing the promoted
|
||||
// credential is irreversible. The #5028 heal therefore applies
|
||||
// exactly when the source is readable and the identities match.
|
||||
let sourceReadErrorCode: string | null = null;
|
||||
const sourceBytes = await fs
|
||||
.readFile(path.join(sourceHome, "auth.json"))
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
|
||||
sourceReadErrorCode = error.code ?? "unknown";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const sourceIdentity = sourceBytes ? readSubscriptionAccountId(sourceBytes) : null;
|
||||
keepPromotedAuth = sourceIdentity !== targetIdentity;
|
||||
if (keepPromotedAuth && sourceReadErrorCode) {
|
||||
// Deferred heal, made visible: seeding runs before every probe and
|
||||
// every execute, so the next call with a readable source applies
|
||||
// the same-identity symlink heal this call could not decide.
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Keeping the existing subscription auth.json in Codex home "${targetHome}" (shared source read failed: ${sourceReadErrorCode}); the next seed with a readable source reconciles it.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (keepPromotedAuth) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Keeping the promoted subscription auth.json in Codex home "${targetHome}".\n`,
|
||||
);
|
||||
} else {
|
||||
await fs.rm(authPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seedFromShared) {
|
||||
for (const name of SYMLINKED_SHARED_FILES) {
|
||||
// The kept promoted credential is authoritative for this home; the shared
|
||||
// symlink would silently swap the account back to the host login.
|
||||
if (name === "auth.json" && keepPromotedAuth) continue;
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureSymlink(path.join(targetHome, name), source);
|
||||
|
|
|
|||
Loading…
Reference in New Issue