diff --git a/packages/adapters/codex-local/src/server/adapter-auth-promotion.test.ts b/packages/adapters/codex-local/src/server/adapter-auth-promotion.test.ts index da57c9ae98..c2e0e52e26 100644 --- a/packages/adapters/codex-local/src/server/adapter-auth-promotion.test.ts +++ b/packages/adapters/codex-local/src/server/adapter-auth-promotion.test.ts @@ -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); diff --git a/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts b/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts index 6c532bc20c..dda087ef83 100644 --- a/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts +++ b/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts @@ -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 { diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts index 83e7ffb89c..156b0f49c2 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -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 { diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 8fe35fa74b..c69f955915 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -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).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 diff --git a/packages/adapters/codex-local/src/server/test.remote.test.ts b/packages/adapters/codex-local/src/server/test.remote.test.ts index 1bcf36fd0b..6376a001ae 100644 --- a/packages/adapters/codex-local/src/server/test.remote.test.ts +++ b/packages/adapters/codex-local/src/server/test.remote.test.ts @@ -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 { + 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); + }); }); diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 0e7abe6cee..8d1586a2e4 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -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;