fix: make Codex sign-in and the environment test agree on the credential a run uses (#13064)
## 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 with a device-code login, and the agent page has a Test button that probes the sandbox with the credentials a real run would use > - The login stored its credential where the Test never looked: the company Codex home kept an old shape-valid credential, so the Test failed with "authentication needed" right after a successful sign-in > - The Test also staged a different Codex home than a real run resolves, so the Test and real runs could disagree in both directions > - This pull request makes the login, the seeding pass, and the Test probe agree on one credential resolution > - The benefit is that a sign-in from the agent page fixes the Test on the next click, and a green Test means the same thing a real run experiences ## Linked Issues or Issue Description **What happened?** Sign in with Codex works during onboarding but not on the agent detail page. The operator completes the device-code login. The panel reports success. The Test button still reports that authentication is needed. No number of repeat logins changes the result. Three defects combine to cause this: 1. The device-login promotion only wrote the company default Codex home when that home held no shape-valid credential. A stale credential (for example a symlink to an old host login) blocked the write forever, so the fresh login stayed invisible to the Test. 2. The seeding pass that runs before every probe and execute replaced a same-identity regular-file `auth.json` with a symlink to the host credential, with no freshness comparison. Even a freshly promoted credential was deleted on the next Test. 3. The sandbox hello probe always staged the company default home. An agent with a configured `CODEX_HOME` was tested against one credential and ran with another. **Expected behavior** A completed sign-in updates the credential the Test probes. The Test stages the same Codex home a real run resolves. A stale credential never outranks a strictly newer one from an interactive login. **Steps to reproduce** 1. Configure a company whose Codex home holds a shape-valid credential that no longer authenticates (for example an old host login symlink). 2. Open a `codex_local` agent's detail page with a sandbox environment and press Test. The result shows the authentication-needed check. 3. Complete the "Sign in with Codex" device-code flow from the panel. 4. Press Test again. Before this change the result still shows authentication needed. ## What Changed - `packages/adapters/codex-local/src/server/adapter-auth-promotion.ts`: the promotion writes the company default home unconditionally. The shared `last_refresh` merge predicate scopes the write. It seeds an absent or unusable slot, refreshes a same-identity slot only with a strictly newer credential, and keeps a slot a different account or an API-key file holds. The atomic rename replaces a symlinked `auth.json` at the link itself. It never writes through into the host home. - `packages/adapters/codex-local/src/server/codex-home.ts`: the same-identity heal in `seedManagedCodexHome` is freshness-aware. A regular-file credential is swapped for the shared symlink only when the shared source is strictly fresher by `last_refresh`. Ties and unparseable timestamps keep the file, which matches the predicate's fail-closed direction. A genuine stale copy still heals as soon as the host credential rotates past it. - `packages/adapters/codex-local/src/server/test.ts`: the sandbox hello probe prepares and stages the same home a real run resolves. The identity-anchored cache vend runs first. A configured managed `CODEX_HOME` is seeded in place and staged. A genuine external override is staged as-is and never seeded or mutated. - Tests: new pins for the strictly-newer company-home refresh, the different-account keep, the symlink-replaced-without-writing-its-target property, the freshness-aware heal (newer kept, older healed, ties kept), the configured-home staging, and the external-home no-mutation proof. The remote-probe suite now pins `CODEX_HOME`/`PAPERCLIP_HOME` to scratch directories so no test can touch a real `~/.codex`. ## Verification - `pnpm exec vitest run packages/adapters/codex-local/src --root packages/adapters/codex-local` — 378 passed, 1 skipped. - Server suites for device login, reconciliation, and the codex adapter (8 files) — 128 passed, 15 skipped. - `tsc --noEmit` clean in the adapter package. ## Risks - Behavioral shift is scoped by the shared merge predicate: only a strictly newer same-identity login can displace a company-home credential, so a second account still never takes over the company slot, and API-key files are never displaced. - The heal keeps ties and unparseable timestamps instead of swapping. A kept file self-corrects on a later seed once the source is provably fresher; deleting a promoted credential is irreversible, so the failure direction is chosen deliberately. - The probe change makes the Test exercise the credential a run uses. A Test that previously passed against the company home while the agent's configured home was broken now fails honestly. ## Model Used Claude (Anthropic) — Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use in Claude Code (terminal). Diagnosis traced through the live promotion locks, the on-disk Codex homes, and the adapter's credential-resolution code paths. **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
This commit is contained in:
parent
2043e0c735
commit
fe5e68d7a5
|
|
@ -1,4 +1,4 @@
|
|||
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -373,6 +373,94 @@ describe("device-login credential promotion", () => {
|
|||
expect(JSON.parse(homeAuth).tokens.account_id).toBe(ACCOUNT);
|
||||
});
|
||||
|
||||
it("a strictly-newer same-account login refreshes the company default home", async () => {
|
||||
// The sign-in loop this pins: a company home already holding a shape-usable
|
||||
// credential for this account must still pick up the login the user just
|
||||
// completed, or every environment test after the login keeps staging the
|
||||
// old credential and keeps reporting authentication as missing.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "old" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.last_refresh).toBe(NEWER);
|
||||
expect(homeAuth.tokens.refresh_token).toContain("new");
|
||||
});
|
||||
|
||||
it("an older same-account login keeps the company default home", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "keep" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.tokens.refresh_token).toContain("keep");
|
||||
});
|
||||
|
||||
it("a strictly-newer same-account login replaces a symlinked company auth.json without writing its target", async () => {
|
||||
// The company home often symlinks auth.json at the host login (the shared
|
||||
// source seeding does exactly that). The refresh must swap the symlink for
|
||||
// a regular file holding the login credential — atomically, at the link
|
||||
// itself — and must never write through the link into the file it names.
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
const hostAuthPath = path.join(home, "host-auth.json");
|
||||
const hostBytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "host" });
|
||||
await writeFile(hostAuthPath, hostBytes);
|
||||
const companyHome = resolveManagedCodexHomeDir(env, COMPANY_A);
|
||||
await mkdir(companyHome, { recursive: true, mode: 0o700 });
|
||||
await symlink(hostAuthPath, companyHomeAuthPath(env, COMPANY_A));
|
||||
|
||||
await promoteDeviceLoginCredential({
|
||||
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "fresh" }),
|
||||
companyId: COMPANY_A,
|
||||
userInitiated: true,
|
||||
checkReadiness: ready,
|
||||
isSoleActiveOwner: soleOwner,
|
||||
env,
|
||||
log: noopLog,
|
||||
});
|
||||
|
||||
const stat = await lstat(companyHomeAuthPath(env, COMPANY_A));
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
|
||||
expect(homeAuth.tokens.refresh_token).toContain("fresh");
|
||||
// The symlink target — standing in for the host's ~/.codex/auth.json — was
|
||||
// never written.
|
||||
expect(await readFile(hostAuthPath, "utf8")).toBe(hostBytes.toString("utf8"));
|
||||
});
|
||||
|
||||
it("promotion fails the login when the account identifier cannot become a handle", async () => {
|
||||
const home = await makeInstanceRoot();
|
||||
const env = envFor(home);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@ import { assertUsableSubscriptionShape } from "./device-login-export.js";
|
|||
//
|
||||
// This account's own home is the durable result of a login: the write is fail
|
||||
// loud, and the caller names it with a company secret so any agent can bind to
|
||||
// it. The company default home is a best-effort fallback: it is seeded only the
|
||||
// first time any account logs in for the company (while it holds no usable
|
||||
// credential yet), and a write failure there never fails the login.
|
||||
// it. The company default home write is best-effort — a failure there never
|
||||
// fails the login — and is scoped by the shared decision predicate: it seeds an
|
||||
// absent or unusable slot, refreshes a same-identity slot only with a
|
||||
// strictly-newer credential, and keeps a slot a different account holds.
|
||||
//
|
||||
// Two decisions gate the write:
|
||||
// - Decision C: only a user-initiated login seeds a home. An automatic
|
||||
|
|
@ -99,8 +100,9 @@ export async function checkStagedCredentialReadiness(
|
|||
*
|
||||
* - `promoted`: the helper wrote this account's own home (a seed for a first
|
||||
* login of this account, or a strictly-newer update for a repeat login). The
|
||||
* helper also seeds the company default home when it holds no usable
|
||||
* credential yet.
|
||||
* helper also best-effort writes the company default home: a seed when the
|
||||
* slot is absent or unusable, a refresh when this same account's login is
|
||||
* strictly newer than what the slot holds.
|
||||
* - `kept`: the login carried a credential that is not newer than what this
|
||||
* account's own home already holds, so the home was kept as-is. This is
|
||||
* still a successful authentication: a later run reads the same home.
|
||||
|
|
@ -263,34 +265,45 @@ export async function promoteDeviceLoginCredential(
|
|||
env,
|
||||
});
|
||||
|
||||
// 5b. Company default home fallback, for an agent with no bound secret. Seed
|
||||
// it only the first time any account logs in for the company, i.e. only
|
||||
// while it holds no usable credential yet; a login for a second account
|
||||
// must never touch it once some account has claimed it. This write is
|
||||
// best-effort: this account's own home above is already durable, so a
|
||||
// failure here (a permission error, a full disk, a lock timeout) must not
|
||||
// fail the promotion.
|
||||
// 5b. Company default home, for an agent with no bound secret. The write runs
|
||||
// unconditionally; the shared decision predicate inside the writer scopes
|
||||
// it. It seeds an absent or unusable slot, refreshes a same-identity slot
|
||||
// only with a strictly-newer credential, and keeps a slot a different
|
||||
// account (or an API-key file) holds — so a login for a second account
|
||||
// still never steals the company slot once some account has claimed it.
|
||||
//
|
||||
// Unconditional matters: a company home can hold a shape-usable credential
|
||||
// that no longer authenticates (for example a symlink to a stale host
|
||||
// login). A shape gate here would skip the write, and every environment
|
||||
// test after this login would keep staging the failing credential and keep
|
||||
// reporting authentication as missing — the login the user just completed
|
||||
// could never change the outcome. The writer's atomic rename replaces a
|
||||
// symlinked auth.json with a regular file; it never writes through the
|
||||
// symlink into the host home.
|
||||
//
|
||||
// This write is best-effort: this account's own home above is already
|
||||
// durable, so a failure here (a permission error, a full disk, a lock
|
||||
// timeout) must not fail the promotion.
|
||||
const companyHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
if (!(await codexHomeHasUsableAuth(companyHome))) {
|
||||
try {
|
||||
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
|
||||
const companyHomeAuthPath = path.join(companyHome, AUTH_FILE_NAME);
|
||||
await writeCredentialSeedOrNewer({
|
||||
sourceBytes: authBytes,
|
||||
destinationPath: companyHomeAuthPath,
|
||||
seedIfDestAbsent: true,
|
||||
log,
|
||||
writtenLine: "[paperclip] Codex device-login promotion: seeded the company default home.",
|
||||
keptLine: "[paperclip] Codex device-login promotion: kept the company default home.",
|
||||
tempPrefix: "auth.json.promotion-home",
|
||||
errorLabel: "codex device-login promotion",
|
||||
env,
|
||||
});
|
||||
} catch {
|
||||
await log(
|
||||
"[paperclip] Codex device-login promotion: seeding the company default home failed; this account's own home is durable, so the login stays successful.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
|
||||
const companyHomeAuthPath = path.join(companyHome, AUTH_FILE_NAME);
|
||||
await writeCredentialSeedOrNewer({
|
||||
sourceBytes: authBytes,
|
||||
destinationPath: companyHomeAuthPath,
|
||||
seedIfDestAbsent: true,
|
||||
log,
|
||||
writtenLine:
|
||||
"[paperclip] Codex device-login promotion: wrote the company default home (seed or strictly-newer refresh).",
|
||||
keptLine: "[paperclip] Codex device-login promotion: kept the company default home.",
|
||||
tempPrefix: "auth.json.promotion-home",
|
||||
errorLabel: "codex device-login promotion",
|
||||
env,
|
||||
});
|
||||
} catch {
|
||||
await log(
|
||||
"[paperclip] Codex device-login promotion: seeding the company default home failed; this account's own home is durable, so the login stays successful.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -403,11 +403,17 @@ describe("seedManagedCodexHome", () => {
|
|||
|
||||
// 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) =>
|
||||
// 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}`,
|
||||
|
|
@ -415,7 +421,7 @@ describe("seedManagedCodexHome", () => {
|
|||
refresh_token: `synthetic-refresh-token-${marker}`,
|
||||
account_id: accountId,
|
||||
},
|
||||
last_refresh: "2026-07-09T00:00:00Z",
|
||||
...(lastRefresh ? { last_refresh: lastRefresh } : {}),
|
||||
});
|
||||
|
||||
it("keeps a promoted subscription auth.json when the shared source has no auth", async () => {
|
||||
|
|
@ -463,18 +469,20 @@ describe("seedManagedCodexHome", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("still replaces a same-identity stale regular copy with the shared symlink (#5028)", async () => {
|
||||
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");
|
||||
const fresh = subscriptionAuth("acct-same", "fresh");
|
||||
// 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"),
|
||||
subscriptionAuth("acct-same", "stale", "2026-07-09T01:00:00Z"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
|
@ -488,6 +496,73 @@ describe("seedManagedCodexHome", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,29 @@ function readApiKeyFromAuthPayload(authPayload: unknown): string | null {
|
|||
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `last_refresh` timestamp of an auth.json payload, in epoch milliseconds,
|
||||
* or null when the bytes are unreadable or carry no parseable timestamp. This is
|
||||
* the same freshness field the shared merge decision predicate
|
||||
* (`codex-auth-merge-decision.cjs`) compares, read the same way, so the seeding
|
||||
* heal below and the credential writers agree on what "fresher" means.
|
||||
*/
|
||||
function readAuthLastRefreshMs(bytes: Buffer | null): number | null {
|
||||
if (!bytes) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bytes.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const raw = (parsed as Record<string, unknown>).last_refresh;
|
||||
const ms = typeof raw === "string" ? Date.parse(raw) : NaN;
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
export function resolveSharedCodexHomeDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
|
|
@ -574,10 +597,11 @@ export async function stageCodexHomeForSync(
|
|||
* 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. 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.
|
||||
* `auth.json` holding a subscription identity the shared source does not hold,
|
||||
* or the same identity with a `last_refresh` the shared source has not strictly
|
||||
* moved past — 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,
|
||||
|
|
@ -601,14 +625,25 @@ export async function seedManagedCodexHome(
|
|||
// 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.
|
||||
// The discriminator is identity- and freshness-anchored, like the promotion
|
||||
// and the cache vend: keep the file when it holds a usable subscription
|
||||
// identity that the shared source does not also hold, and also when it holds
|
||||
// the SAME identity but the shared source is not strictly fresher by
|
||||
// `last_refresh`. A device login for the account the host is also signed in
|
||||
// to promotes a file strictly newer than the host copy; swapping that file
|
||||
// for the symlink would sign the company back in with the very credential the
|
||||
// login just replaced — the failing one that made the user sign in. The
|
||||
// #5028 stale copy is the strictly-older direction of the same comparison,
|
||||
// and it still heals: the live host credential refreshes on use, so as soon
|
||||
// as the shared source is strictly fresher the swap applies. Ties and
|
||||
// unparseable freshness keep the file — the same fail-closed direction the
|
||||
// shared merge decision predicate uses — because deleting a promoted
|
||||
// credential is irreversible while keeping it self-corrects on the next seed
|
||||
// once the source has provably moved past it. 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");
|
||||
|
|
@ -640,7 +675,20 @@ export async function seedManagedCodexHome(
|
|||
return null;
|
||||
});
|
||||
const sourceIdentity = sourceBytes ? readSubscriptionAccountId(sourceBytes) : null;
|
||||
keepPromotedAuth = sourceIdentity !== targetIdentity;
|
||||
if (sourceIdentity !== targetIdentity) {
|
||||
keepPromotedAuth = true;
|
||||
} else {
|
||||
// Same identity: swap to the symlink only when the shared source is
|
||||
// strictly fresher. A tie or an unparseable timestamp keeps the file
|
||||
// (see the freshness rationale above).
|
||||
const sourceLastRefresh = readAuthLastRefreshMs(sourceBytes);
|
||||
const targetLastRefresh = readAuthLastRefreshMs(targetBytes);
|
||||
keepPromotedAuth = !(
|
||||
sourceLastRefresh !== null &&
|
||||
targetLastRefresh !== null &&
|
||||
sourceLastRefresh > targetLastRefresh
|
||||
);
|
||||
}
|
||||
if (keepPromotedAuth && sourceReadErrorCode) {
|
||||
// Deferred heal, made visible: seeding runs before every probe and
|
||||
// every execute, so the next call with a readable source applies
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const {
|
||||
|
|
@ -14,14 +15,19 @@ const {
|
|||
prepareManagedCodexHome,
|
||||
restoreWorkspace,
|
||||
capturedHomeAssetFiles,
|
||||
capturedHomeAssetAuthJson,
|
||||
} = vi.hoisted(() => {
|
||||
const restoreWorkspace = vi.fn(async () => {});
|
||||
// Records the files staged in the uploaded "home" asset at call time, before
|
||||
// the probe's cleanup deletes the temp dir. Lets tests assert the upload is a
|
||||
// minimal credentials-only home and not the full managed CODEX_HOME.
|
||||
const capturedHomeAssetFiles: { value: string[] | null } = { value: null };
|
||||
// Records the staged auth.json content, so tests can assert WHICH home's
|
||||
// credential the probe uploaded (the effective home a run would use).
|
||||
const capturedHomeAssetAuthJson: { value: string | null } = { value: null };
|
||||
return {
|
||||
capturedHomeAssetFiles,
|
||||
capturedHomeAssetAuthJson,
|
||||
ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}),
|
||||
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
|
||||
maybeRunSandboxInstallCommand: vi.fn(async () => null),
|
||||
|
|
@ -50,6 +56,9 @@ const {
|
|||
const homeAsset = input?.assets?.find((asset) => asset.key === "home");
|
||||
if (homeAsset) {
|
||||
capturedHomeAssetFiles.value = (await fs.readdir(homeAsset.localDir)).sort();
|
||||
capturedHomeAssetAuthJson.value = await fs
|
||||
.readFile(`${homeAsset.localDir}/auth.json`, "utf8")
|
||||
.catch(() => null);
|
||||
}
|
||||
return {
|
||||
target: null,
|
||||
|
|
@ -100,9 +109,34 @@ vi.mock("./codex-home.js", async () => {
|
|||
import { testEnvironment } from "./test.js";
|
||||
|
||||
describe("codex remote environment diagnostics", () => {
|
||||
afterEach(() => {
|
||||
const scratchDirs: string[] = [];
|
||||
|
||||
async function makeScratchDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
scratchDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// The probe mirrors execute's home preparation, which reads the shared
|
||||
// source home and the auth cache from `process.env`. Pin both to empty
|
||||
// scratch locations so no test ever reads or writes the real ~/.codex or
|
||||
// the real instance tree.
|
||||
vi.stubEnv("CODEX_HOME", await makeScratchDir("paperclip-test-shared-codex-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", await makeScratchDir("paperclip-test-instance-"));
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
capturedHomeAssetFiles.value = null;
|
||||
capturedHomeAssetAuthJson.value = null;
|
||||
while (scratchDirs.length > 0) {
|
||||
const dir = scratchDirs.pop();
|
||||
if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("stages managed CODEX_HOME in an isolated runtime dir and keeps the probe cwd on the original remote workspace", async () => {
|
||||
|
|
@ -318,4 +352,114 @@ describe("codex remote environment diagnostics", () => {
|
|||
| undefined;
|
||||
expect(probeCall?.[4].env.CODEX_HOME).toBeUndefined();
|
||||
});
|
||||
|
||||
const subscriptionAuth = (accountId: string, marker: string, lastRefresh: 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: lastRefresh,
|
||||
});
|
||||
|
||||
function sandboxTarget(): AdapterExecutionTarget {
|
||||
return {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "daytona",
|
||||
remoteCwd: "/remote/workspace",
|
||||
runner: {
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stages a configured managed per-agent CODEX_HOME instead of the company default home", async () => {
|
||||
// Execute honors env.CODEX_HOME, so the probe must exercise that same home
|
||||
// — otherwise the Test and real runs authenticate with different
|
||||
// credentials and can disagree in both directions.
|
||||
const perAgentHome = path.join(
|
||||
process.env.PAPERCLIP_HOME!,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-x",
|
||||
"codex-home",
|
||||
);
|
||||
const promoted = subscriptionAuth("acct-agent", "promoted", "2026-07-09T02:00:00Z");
|
||||
await fs.mkdir(perAgentHome, { recursive: true });
|
||||
await fs.writeFile(path.join(perAgentHome, "auth.json"), promoted, "utf8");
|
||||
await fs.writeFile(path.join(perAgentHome, "config.toml"), 'model = "gpt-5"\n', "utf8");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "codex",
|
||||
env: { CODEX_HOME: perAgentHome },
|
||||
},
|
||||
executionTarget: sandboxTarget(),
|
||||
environmentName: "QA Daytona",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
// The company default home preparation never ran; the configured managed
|
||||
// home was seeded in place and its credential is what got staged.
|
||||
expect(prepareManagedCodexHome).not.toHaveBeenCalled();
|
||||
expect(capturedHomeAssetAuthJson.value).toBe(promoted);
|
||||
// The real seeding pass ran on the per-agent home and kept the promoted
|
||||
// regular-file credential (the shared source scratch home is empty).
|
||||
const stat = await fs.lstat(path.join(perAgentHome, "auth.json"));
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(await fs.readFile(path.join(perAgentHome, "auth.json"), "utf8")).toBe(promoted);
|
||||
});
|
||||
|
||||
it("stages an external CODEX_HOME's credentials as-is and never seeds or mutates it", async () => {
|
||||
const externalHome = await makeScratchDir("paperclip-test-external-codex-");
|
||||
const external = subscriptionAuth("acct-ext", "external", "2026-07-09T01:00:00Z");
|
||||
await fs.writeFile(path.join(externalHome, "auth.json"), external, "utf8");
|
||||
// Plant a same-identity, strictly-fresher credential in the shared source
|
||||
// home: if the probe wrongly ran the managed seeding pass on the external
|
||||
// home, the heal would swap its auth.json for a symlink to this file. The
|
||||
// regular-file assertion below is therefore proof no seeding happened.
|
||||
await fs.writeFile(
|
||||
path.join(process.env.CODEX_HOME!, "auth.json"),
|
||||
subscriptionAuth("acct-ext", "shared", "2026-07-09T02:00:00Z"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "codex",
|
||||
env: { CODEX_HOME: externalHome },
|
||||
},
|
||||
executionTarget: sandboxTarget(),
|
||||
environmentName: "QA Daytona",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(prepareManagedCodexHome).not.toHaveBeenCalled();
|
||||
// The external home's own credential is what got staged — not the shared
|
||||
// source's fresher copy, because an external override manages its own auth.
|
||||
expect(capturedHomeAssetAuthJson.value).toBe(external);
|
||||
const stat = await fs.lstat(path.join(externalHome, "auth.json"));
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(await fs.readFile(path.join(externalHome, "auth.json"), "utf8")).toBe(external);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,17 @@ import { parseCodexJsonl } from "./parse.js";
|
|||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
|
||||
import { buildCodexExecArgs } from "./codex-args.js";
|
||||
import { prepareManagedCodexHome } from "./codex-home.js";
|
||||
import {
|
||||
isManagedCodexHomePath,
|
||||
prepareManagedCodexHome,
|
||||
resolveSharedCodexHomeDir,
|
||||
seedManagedCodexHome,
|
||||
} from "./codex-home.js";
|
||||
import {
|
||||
isCodexAuthCacheEnabled,
|
||||
resolveCodexAuthCacheEntryPath,
|
||||
selectVendCredential,
|
||||
} from "./codex-auth-cache.js";
|
||||
import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
|
||||
|
|
@ -94,23 +104,58 @@ async function prepareCodexHelloProbe(input: {
|
|||
};
|
||||
|
||||
if (input.targetIsRemote && !input.probeApiKey) {
|
||||
const managedHome = await prepareManagedCodexHome(process.env, async () => {}, input.companyId, {
|
||||
apiKey: null,
|
||||
});
|
||||
// Prepare the exact home a real run would use, mirroring execute.ts: vend
|
||||
// the shared credential's freshest same-identity cached copy, then seed the
|
||||
// effective home — the company default when no CODEX_HOME is configured, a
|
||||
// Paperclip-managed override (the per-agent home) seeded in place — and
|
||||
// stage that home's credentials. A genuine external override manages its
|
||||
// own auth: its bytes are staged as-is and it is never seeded or mutated.
|
||||
// Without this mirror the probe exercises a different credential than the
|
||||
// run, and the Test and real runs can disagree in both directions.
|
||||
const configuredCodexHome = isNonEmpty(input.env.CODEX_HOME)
|
||||
? path.resolve(input.env.CODEX_HOME.trim())
|
||||
: null;
|
||||
const configuredHomeIsManaged =
|
||||
configuredCodexHome != null &&
|
||||
isManagedCodexHomePath(process.env, input.companyId, configuredCodexHome);
|
||||
if (isCodexAuthCacheEnabled(process.env)) {
|
||||
// Identity-anchored cache vend, exactly as execute runs it before the
|
||||
// seeding below. Best-effort: a vend failure never blocks the probe, and
|
||||
// the probe then stages the shared credential as-is.
|
||||
const sharedHomeAuthPath = path.join(resolveSharedCodexHomeDir(process.env), "auth.json");
|
||||
await selectVendCredential(
|
||||
sharedHomeAuthPath,
|
||||
(accountId) => resolveCodexAuthCacheEntryPath(process.env, accountId, input.companyId),
|
||||
async () => {},
|
||||
).catch(() => undefined);
|
||||
}
|
||||
let effectiveHome: string;
|
||||
if (configuredCodexHome == null) {
|
||||
effectiveHome = await prepareManagedCodexHome(process.env, async () => {}, input.companyId, {
|
||||
apiKey: null,
|
||||
});
|
||||
} else {
|
||||
if (configuredHomeIsManaged) {
|
||||
await seedManagedCodexHome(configuredCodexHome, process.env, async () => {}, {
|
||||
apiKey: null,
|
||||
});
|
||||
}
|
||||
effectiveHome = configuredCodexHome;
|
||||
}
|
||||
|
||||
// Upload only the credential/config files the login probe needs, not the
|
||||
// entire managed CODEX_HOME. A real managed home accumulates hundreds of MB
|
||||
// of session/state history (`sessions/`, `state_*.sqlite`, …); tarring and
|
||||
// streaming all of it into the sandbox made the environment Test probe take
|
||||
// many minutes and look like it hung. The hello probe only needs auth.
|
||||
// entire effective CODEX_HOME. A real managed home accumulates hundreds of
|
||||
// MB of session/state history (`sessions/`, `state_*.sqlite`, …); tarring
|
||||
// and streaming all of it into the sandbox made the environment Test probe
|
||||
// take many minutes and look like it hung. The hello probe only needs auth.
|
||||
probeHomeLocalDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), `paperclip-codex-probe-home-${input.runId}-`),
|
||||
);
|
||||
let seededAuth = false;
|
||||
for (const file of ["auth.json", "config.toml"]) {
|
||||
// `fs.readFile` follows the managed home's `auth.json` symlink into the
|
||||
// host's `~/.codex`, so we copy the resolved bytes as a plain file.
|
||||
const contents = await fs.readFile(path.join(managedHome, file)).catch(() => null);
|
||||
// `fs.readFile` follows the home's `auth.json` symlink into the host's
|
||||
// `~/.codex`, so we copy the resolved bytes as a plain file.
|
||||
const contents = await fs.readFile(path.join(effectiveHome, file)).catch(() => null);
|
||||
if (contents) {
|
||||
await fs.writeFile(path.join(probeHomeLocalDir, file), contents);
|
||||
if (file === "auth.json") seededAuth = true;
|
||||
|
|
|
|||
Loading…
Reference in New Issue