Skip to content

Commit 9e8c585

Browse files
feat(adapter-codex-local): add device-login credential export
Install the sandbox credential into a unique, run-scoped, private proof home under a company-scoped root. Reject the default, shared, and managed homes, reject a symlink or a non-regular path, and reject an API-key, malformed, or oversized payload before any write. Create the root and the home at mode 0700 and stage auth.json at mode 0600 under a directory lock. Seed an empty home and delegate a strictly-newer, same-identity update to the reused copy-back helper. Add removeProofHome for the cleanup step. Never log token bytes. Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 63b5929 commit 9e8c585

2 files changed

Lines changed: 572 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
import {
7+
MAX_AUTH_JSON_BYTES,
8+
deriveProofHome,
9+
installDeviceLoginCredential,
10+
removeProofHome,
11+
resolveProofHomeRoot,
12+
} from "./device-login-export.js";
13+
import { resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
14+
15+
const COMPANY = "company-a";
16+
const RUN = "run-1234";
17+
const NEWER = "2026-07-09T02:00:00Z";
18+
const OLDER = "2026-07-09T01:00:00Z";
19+
const ACCOUNT = "acct-42";
20+
const OTHER_ACCOUNT = "acct-99";
21+
const TOKEN_SENTINEL = "SENTINEL_TOKEN_XYZ";
22+
23+
describe("device-login credential export", () => {
24+
const cleanupDirs: string[] = [];
25+
26+
afterEach(async () => {
27+
while (cleanupDirs.length > 0) {
28+
const dir = cleanupDirs.pop();
29+
if (!dir) continue;
30+
await chmod(dir, 0o700).catch(() => undefined);
31+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
32+
}
33+
});
34+
35+
async function makeInstanceRoot(): Promise<string> {
36+
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-proof-"));
37+
cleanupDirs.push(dir);
38+
return dir;
39+
}
40+
41+
function envFor(instanceHome: string, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
42+
return { PAPERCLIP_HOME: instanceHome, PAPERCLIP_INSTANCE_ID: "default", ...extra };
43+
}
44+
45+
function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): Buffer {
46+
const suffix = input.marker ?? input.accountId;
47+
return Buffer.from(
48+
JSON.stringify({
49+
tokens: {
50+
id_token: `id-token-${suffix}`,
51+
access_token: `access-token-${suffix}`,
52+
refresh_token: `${TOKEN_SENTINEL}-${suffix}`,
53+
account_id: input.accountId,
54+
},
55+
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
56+
}),
57+
);
58+
}
59+
60+
const noopLog = (_line: string): void => {};
61+
62+
it("deriveProofHome returns a unique, run-scoped path under a company-scoped root", async () => {
63+
const home = await makeInstanceRoot();
64+
const env = envFor(home);
65+
const root = resolveProofHomeRoot(env, COMPANY);
66+
expect(root).toBe(
67+
path.resolve(home, "instances", "default", "companies", COMPANY, "codex-device-login-proof"),
68+
);
69+
const a = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
70+
const b = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
71+
expect(a.startsWith(root + path.sep)).toBe(true);
72+
expect(a).toContain(RUN);
73+
expect(a).not.toBe(b); // unique per call
74+
// Never the shared or the managed home.
75+
expect(a).not.toBe(resolveSharedCodexHomeDir(env));
76+
expect(a).not.toBe(resolveManagedCodexHomeDir(env, COMPANY));
77+
});
78+
79+
it("export_creates_root_and_home_at_mode_0700", async () => {
80+
const home = await makeInstanceRoot();
81+
const env = envFor(home);
82+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
83+
await installDeviceLoginCredential({
84+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
85+
proofHome,
86+
env,
87+
companyId: COMPANY,
88+
log: noopLog,
89+
});
90+
const root = resolveProofHomeRoot(env, COMPANY);
91+
expect((await stat(root)).mode & 0o777).toBe(0o700);
92+
expect((await stat(proofHome)).mode & 0o777).toBe(0o700);
93+
});
94+
95+
it("export_seeds_empty_proof_home_with_mode_0600", async () => {
96+
const home = await makeInstanceRoot();
97+
const env = envFor(home);
98+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
99+
const outcome = await installDeviceLoginCredential({
100+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
101+
proofHome,
102+
env,
103+
companyId: COMPANY,
104+
log: noopLog,
105+
});
106+
expect(outcome).toBe("seeded");
107+
const authPath = path.join(proofHome, "auth.json");
108+
expect((await stat(authPath)).mode & 0o777).toBe(0o600);
109+
const written = JSON.parse(await readFile(authPath, "utf8"));
110+
expect(written.tokens.account_id).toBe(ACCOUNT);
111+
});
112+
113+
it("export_rejects_default_shared_and_managed_home", async () => {
114+
const home = await makeInstanceRoot();
115+
const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
116+
const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER });
117+
const shared = resolveSharedCodexHomeDir(env);
118+
const managed = resolveManagedCodexHomeDir(env, COMPANY);
119+
const agentManaged = path.resolve(managed, "..", "agents", "agent-1", "codex-home");
120+
for (const target of [shared, managed, agentManaged]) {
121+
await expect(
122+
installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: target, env, companyId: COMPANY, log: noopLog }),
123+
).rejects.toThrow();
124+
}
125+
});
126+
127+
it("export_rejects_symlink_or_non_regular_path", async () => {
128+
const home = await makeInstanceRoot();
129+
const env = envFor(home);
130+
const root = resolveProofHomeRoot(env, COMPANY);
131+
await mkdir(root, { recursive: true, mode: 0o700 });
132+
const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER });
133+
134+
// A symlinked proof home is rejected.
135+
const realDir = path.join(home, "elsewhere");
136+
await mkdir(realDir, { recursive: true, mode: 0o700 });
137+
const linkedHome = path.join(root, `${RUN}-linked`);
138+
await symlink(realDir, linkedHome);
139+
await expect(
140+
installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: linkedHome, env, companyId: COMPANY, log: noopLog }),
141+
).rejects.toThrow();
142+
143+
// A proof home whose auth.json is a symlink is rejected.
144+
const symAuthHome = path.join(root, `${RUN}-symauth`);
145+
await mkdir(symAuthHome, { recursive: true, mode: 0o700 });
146+
await symlink(path.join(home, "target.json"), path.join(symAuthHome, "auth.json"));
147+
await expect(
148+
installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: symAuthHome, env, companyId: COMPANY, log: noopLog }),
149+
).rejects.toThrow();
150+
});
151+
152+
it("export_rejects_api_key_malformed_or_oversized_payload", async () => {
153+
const home = await makeInstanceRoot();
154+
const env = envFor(home);
155+
const apiKey = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-secret-key" }));
156+
const malformed = Buffer.from("this is not json {");
157+
const oversized = Buffer.concat([
158+
subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
159+
Buffer.alloc(MAX_AUTH_JSON_BYTES + 10, 0x20),
160+
]);
161+
for (const bytes of [apiKey, malformed, oversized]) {
162+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
163+
await expect(
164+
installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome, env, companyId: COMPANY, log: noopLog }),
165+
).rejects.toThrow();
166+
// No file was written on a rejected payload.
167+
await expect(stat(path.join(proofHome, "auth.json"))).rejects.toThrow();
168+
}
169+
});
170+
171+
it("export_updates_home_with_strictly_newer_same_identity", async () => {
172+
const home = await makeInstanceRoot();
173+
const env = envFor(home);
174+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
175+
const authPath = path.join(proofHome, "auth.json");
176+
await installDeviceLoginCredential({
177+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "old" }),
178+
proofHome,
179+
env,
180+
companyId: COMPANY,
181+
log: noopLog,
182+
});
183+
const outcome = await installDeviceLoginCredential({
184+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }),
185+
proofHome,
186+
env,
187+
companyId: COMPANY,
188+
log: noopLog,
189+
});
190+
expect(outcome).toBe("updated");
191+
const written = JSON.parse(await readFile(authPath, "utf8"));
192+
expect(written.last_refresh).toBe(NEWER);
193+
expect(written.tokens.refresh_token).toContain("new");
194+
});
195+
196+
it("export_keeps_home_on_older_or_different_identity", async () => {
197+
const home = await makeInstanceRoot();
198+
const env = envFor(home);
199+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
200+
const authPath = path.join(proofHome, "auth.json");
201+
await installDeviceLoginCredential({
202+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "keep" }),
203+
proofHome,
204+
env,
205+
companyId: COMPANY,
206+
log: noopLog,
207+
});
208+
// Older same identity: kept.
209+
const olderOutcome = await installDeviceLoginCredential({
210+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }),
211+
proofHome,
212+
env,
213+
companyId: COMPANY,
214+
log: noopLog,
215+
});
216+
expect(olderOutcome).toBe("kept");
217+
// Different identity, even newer: kept.
218+
const otherOutcome = await installDeviceLoginCredential({
219+
sandboxAuthBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }),
220+
proofHome,
221+
env,
222+
companyId: COMPANY,
223+
log: noopLog,
224+
});
225+
expect(otherOutcome).toBe("kept");
226+
const written = JSON.parse(await readFile(authPath, "utf8"));
227+
expect(written.tokens.account_id).toBe(ACCOUNT);
228+
expect(written.tokens.refresh_token).toContain("keep");
229+
});
230+
231+
it("export_logs_contain_no_token_bytes", async () => {
232+
const home = await makeInstanceRoot();
233+
const env = envFor(home);
234+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
235+
const logs: string[] = [];
236+
await installDeviceLoginCredential({
237+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
238+
proofHome,
239+
env,
240+
companyId: COMPANY,
241+
log: (line) => logs.push(line),
242+
});
243+
const haystack = logs.join("\n");
244+
expect(haystack).not.toContain(TOKEN_SENTINEL);
245+
expect(haystack).not.toContain(ACCOUNT);
246+
});
247+
248+
it("cleanup_removes_proof_home_and_leaves_default_home_unchanged", async () => {
249+
const home = await makeInstanceRoot();
250+
const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
251+
// A sentinel in a fake shared/default home must survive the cleanup.
252+
const shared = resolveSharedCodexHomeDir(env);
253+
await mkdir(shared, { recursive: true, mode: 0o700 });
254+
const sentinel = path.join(shared, "auth.json");
255+
await writeFile(sentinel, JSON.stringify({ tokens: { account_id: "host", refresh_token: "host" } }), { mode: 0o600 });
256+
257+
const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
258+
await installDeviceLoginCredential({
259+
sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
260+
proofHome,
261+
env,
262+
companyId: COMPANY,
263+
log: noopLog,
264+
});
265+
expect((await lstat(proofHome)).isDirectory()).toBe(true);
266+
267+
await removeProofHome(proofHome, { env, companyId: COMPANY });
268+
await expect(lstat(proofHome)).rejects.toThrow();
269+
// The fake default home is untouched.
270+
expect(await readFile(sentinel, "utf8")).toContain("host");
271+
});
272+
273+
it("removeProofHome refuses a path outside the proof root", async () => {
274+
const home = await makeInstanceRoot();
275+
const env = envFor(home);
276+
const outside = path.join(home, "not-a-proof-home");
277+
await mkdir(outside, { recursive: true });
278+
await expect(removeProofHome(outside, { env, companyId: COMPANY })).rejects.toThrow();
279+
// The outside directory is untouched.
280+
expect((await lstat(outside)).isDirectory()).toBe(true);
281+
});
282+
});

0 commit comments

Comments
 (0)