Skip to content

Commit 541f6d4

Browse files
author
AI Agent
committed
fix(codex): return remote auth refresh to external home
1 parent 19be4cf commit 541f6d4

4 files changed

Lines changed: 112 additions & 17 deletions

File tree

docs/adapters/codex-local.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ A managed home is created empty, so the adapter must provision auth into it befo
7575
instead of copying a stale token into the managed home.
7676
3. **External `CODEX_HOME`:** if adapter env points `CODEX_HOME` outside the
7777
Paperclip-managed company tree, that home is self-managed. Paperclip does
78-
not seed or overwrite it, so its own `auth.json` wins.
78+
not seed it, so its own `auth.json` wins. A remote run stages that credential
79+
and copies a strictly newer same-account OAuth refresh back to that external
80+
source on teardown, matching the mutation a local Codex process would make.
81+
Separate external homes can therefore retain separate subscription identities.
7982

8083
For sandbox or SSH execution, Paperclip uploads the effective managed
8184
`CODEX_HOME` and launches Codex with `CODEX_HOME` pointing at that uploaded
@@ -84,6 +87,11 @@ managed-home mode. If the host has no usable `auth.json` and no per-agent
8487
`OPENAI_API_KEY`, the managed run fails fast instead of falling back to an
8588
in-sandbox login.
8689

90+
For an external `CODEX_HOME`, the uploaded credential remains bound to that
91+
external home. Teardown never redirects its refreshed OAuth credential into the
92+
shared host login, so two agents configured with different external homes cannot
93+
overwrite each other's subscription identity.
94+
8795
Worked example: a worker runs in a sandbox image that already has
8896
`$HOME/.codex/auth.json`, and the Paperclip host is logged in with a ChatGPT
8997
subscription. For a managed `codex_local` agent, Paperclip symlinks the host

packages/adapters/codex-local/src/server/codex-auth-copyback.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const execFile = promisify(execFileCallback);
1818
// predicate answers one question — "should the caller replace `destination`
1919
// with `source`?" — purely by argument order (first = source, second =
2020
// destination). For the copy-back the sandbox credential is the `source` and
21-
// the shared host credential is the `destination`, so exit 10 (use source)
21+
// the host-side source credential is the `destination`, so exit 10 (use source)
2222
// means "install the sandbox copy onto the host" and exit 20 (keep destination)
2323
// means "leave the host copy untouched". The predicate only ever reads the two
2424
// files and exits with a code; it never prints token bytes.
@@ -39,9 +39,9 @@ export interface CopyBackCodexAuthInput {
3939
*/
4040
readSandboxAuth: () => Promise<Buffer>;
4141
/**
42-
* Absolute path of the shared host credential to (maybe) overwrite — the
43-
* symlink *source* the managed Codex homes point their `auth.json` at, never
44-
* an in-sandbox or per-agent symlink.
42+
* Absolute path of the host-side source credential to (maybe) overwrite —
43+
* either the shared source for managed homes or an external CODEX_HOME auth
44+
* source, never an in-sandbox or per-agent managed symlink.
4545
*/
4646
hostAuthPath: string;
4747
/** Non-leaking progress sink: receives decision/outcome lines only. */
@@ -92,7 +92,7 @@ async function decideExitCode(sourcePath: string, destinationPath: string): Prom
9292

9393
/**
9494
* Guards, locks, and atomically installs a strictly-newer sandbox Codex
95-
* `auth.json` onto the shared host credential at teardown.
95+
* `auth.json` onto its host-side source credential at teardown.
9696
*
9797
* Sequence, all under `withDirectoryMergeLock` on the host target's directory
9898
* so a concurrent inbound restore or another copy-back can't interleave:

packages/adapters/codex-local/src/server/execute.test.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1+
import { lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -120,7 +120,14 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
120120
async function runTeardown(input: {
121121
sandboxAuth: string;
122122
hostAuth: string;
123-
}): Promise<{ finalHostAuth: string; finalHostMode: number }> {
123+
externalHostAuth?: string;
124+
externalAuthViaSymlink?: boolean;
125+
}): Promise<{
126+
finalHostAuth: string;
127+
finalHostMode: number;
128+
finalSharedHostAuth: string;
129+
configuredAuthIsSymlink: boolean;
130+
}> {
124131
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-e2e-"));
125132
cleanupDirs.push(rootDir);
126133
const workspaceDir = path.join(rootDir, "workspace");
@@ -132,6 +139,20 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
132139
await mkdir(sharedHostHome, { recursive: true });
133140
const hostAuthPath = path.join(sharedHostHome, "auth.json");
134141
await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 });
142+
const configuredHome = input.externalHostAuth == null
143+
? sharedHostHome
144+
: path.join(rootDir, "external-codex-home");
145+
if (input.externalHostAuth != null) {
146+
await mkdir(configuredHome, { recursive: true });
147+
const configuredAuthPath = path.join(configuredHome, "auth.json");
148+
if (input.externalAuthViaSymlink) {
149+
const externalAuthSource = path.join(rootDir, "external-auth-source.json");
150+
await writeFile(externalAuthSource, input.externalHostAuth, { mode: 0o600 });
151+
await symlink(externalAuthSource, configuredAuthPath);
152+
} else {
153+
await writeFile(configuredAuthPath, input.externalHostAuth, { mode: 0o600 });
154+
}
155+
}
135156

136157
savedCodexHomeEnv = process.env.CODEX_HOME;
137158
process.env.CODEX_HOME = sharedHostHome;
@@ -151,8 +172,9 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
151172
command: "codex",
152173
engine: "cli",
153174
// External CODEX_HOME (outside the managed company tree) so no managed
154-
// seeding rewrites auth.json before teardown; equals the shared host home.
155-
env: { CODEX_HOME: sharedHostHome },
175+
// seeding rewrites auth.json before teardown. Most cases use the shared
176+
// home; multi-subscription coverage supplies a distinct external home.
177+
env: { CODEX_HOME: configuredHome },
156178
},
157179
context: {
158180
paperclipWorkspace: {
@@ -176,8 +198,10 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
176198
});
177199

178200
return {
179-
finalHostAuth: await readFile(hostAuthPath, "utf8"),
180-
finalHostMode: (await lstat(hostAuthPath)).mode & 0o777,
201+
finalHostAuth: await readFile(path.join(configuredHome, "auth.json"), "utf8"),
202+
finalHostMode: (await stat(path.join(configuredHome, "auth.json"))).mode & 0o777,
203+
finalSharedHostAuth: await readFile(hostAuthPath, "utf8"),
204+
configuredAuthIsSymlink: (await lstat(path.join(configuredHome, "auth.json"))).isSymbolicLink(),
181205
};
182206
}
183207

@@ -211,6 +235,42 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
211235
expect(result.finalHostMode).toBe(0o600);
212236
});
213237

238+
it.each([
239+
{ authStorage: "regular file", externalAuthViaSymlink: false },
240+
{ authStorage: "symlink source", externalAuthViaSymlink: true },
241+
])(
242+
"round-trips an external CODEX_HOME identity from a $authStorage without overwriting the shared subscription",
243+
async ({ externalAuthViaSymlink }) => {
244+
const sharedHostAuth = subscriptionAuth({
245+
accountId: "acct-primary",
246+
lastRefresh: "2026-07-09T03:00:00Z",
247+
marker: "shared-primary",
248+
});
249+
const externalHostAuth = subscriptionAuth({
250+
accountId: "acct-secondary",
251+
lastRefresh: "2026-07-09T01:00:00Z",
252+
marker: "external-older",
253+
});
254+
const sandboxAuth = subscriptionAuth({
255+
accountId: "acct-secondary",
256+
lastRefresh: "2026-07-09T02:00:00Z",
257+
marker: "external-refreshed",
258+
});
259+
260+
const result = await runTeardown({
261+
sandboxAuth,
262+
hostAuth: sharedHostAuth,
263+
externalHostAuth,
264+
externalAuthViaSymlink,
265+
});
266+
267+
expect(result.finalHostAuth).toBe(sandboxAuth);
268+
expect(result.finalHostMode).toBe(0o600);
269+
expect(result.finalSharedHostAuth).toBe(sharedHostAuth);
270+
expect(result.configuredAuthIsSymlink).toBe(externalAuthViaSymlink);
271+
},
272+
);
273+
214274
it("keeps the host auth.json when the sandbox copy is a tie or older on teardown", async () => {
215275
const cases = [
216276
{

packages/adapters/codex-local/src/server/execute.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ const executeCodexAcp = createCodexAcpExecutor();
110110
const CODEX_ROLLOUT_NOISE_RE =
111111
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
112112

113+
async function resolveCodexAuthCopyBackPath(input: {
114+
configuredCodexHome: string | null;
115+
configuredHomeIsManaged: boolean;
116+
effectiveCodexHome: string;
117+
}): Promise<string> {
118+
if (input.configuredCodexHome == null || input.configuredHomeIsManaged) {
119+
return path.join(resolveSharedCodexHomeDir(process.env), "auth.json");
120+
}
121+
122+
const externalAuthPath = path.join(input.effectiveCodexHome, "auth.json");
123+
const entry = await fs.lstat(externalAuthPath).catch((error: NodeJS.ErrnoException) => {
124+
if (error.code === "ENOENT") return null;
125+
throw error;
126+
});
127+
if (!entry?.isSymbolicLink()) return externalAuthPath;
128+
129+
const target = await fs.readlink(externalAuthPath);
130+
return path.resolve(path.dirname(externalAuthPath), target);
131+
}
132+
113133
function stripCodexRolloutNoise(text: string): string {
114134
const parts = text.split(/\r?\n/);
115135
const kept: string[] = [];
@@ -759,6 +779,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
759779
// the single-use `auth.json`) are dereferenced to bytes. This drops the
760780
// large runtime state (`sessions/`, `*.sqlite`, `plugins/`, …) that the
761781
// 4-name denylist missed and that a sandbox run never needs.
782+
const authCopyBackPath = await resolveCodexAuthCopyBackPath({
783+
configuredCodexHome,
784+
configuredHomeIsManaged,
785+
effectiveCodexHome,
786+
});
762787
stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId });
763788
return await prepareAdapterExecutionTargetRuntime({
764789
runId,
@@ -789,16 +814,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
789814
// Outbound (sandbox→host) auth copy-back contribution: at
790815
// teardown, read the sandbox's `auth.json` and — guarded by the
791816
// same direction-agnostic decision predicate under a directory
792-
// lock — atomically install it onto the shared host credential
793-
// when it is a strictly-newer same-identity subscription copy.
817+
// lock — atomically install it onto its host-side source
818+
// credential when it is a strictly-newer same-identity copy.
794819
// The sandbox core stays adapter-agnostic; it just awaits this
795820
// generic `restore` seam per asset before destroying the sandbox.
796-
// Target is the shared symlink SOURCE (what managed homes point
797-
// `auth.json` at), not the in-sandbox symlink.
821+
// Target is the actual host-side credential source for this
822+
// binding: the shared source for managed homes, or the external
823+
// CODEX_HOME auth source for a self-managed override. Never
824+
// point copy-back at an in-sandbox or per-agent managed symlink.
798825
restore: async ({ assetDir, readFile }) =>
799826
void (await copyBackCodexAuth({
800827
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
801-
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
828+
hostAuthPath: authCopyBackPath,
802829
log: (line) => onLog("stdout", `${line}\n`),
803830
// Additive cache write (sandbox to host): also cache the
804831
// sandbox subscription credential in its per-identity slot,

0 commit comments

Comments
 (0)