Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 26 additions & 47 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
bootstrapPlugin,
cleanupSdkDirectory,
codexSecurityCredentialAllowsAmbientImport,
codexSecurityCredentialHome,
codexSecurityHasStoredFileCredentials,
codexSecurityStateDirectory,
createIsolatedHome,
Expand Down Expand Up @@ -467,10 +468,7 @@ export class CodexSecurity {
options.auth,
modelProvider,
);
if (
authentication.method === "stored_credentials" &&
this.#dependencies.prepareRuntime === undefined
) {
if (this.#dependencies.prepareRuntime === undefined) {
const credentialHome = await prepareCodexSecurityCredentialHome(
scanEnvironment,
(path) =>
Expand All @@ -492,7 +490,6 @@ export class CodexSecurity {
);
if (
runtime === previousRuntime &&
runtime.persistentCredentialHome === true &&
this.#dependencies.prepareRuntime === undefined
) {
await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal);
Expand Down Expand Up @@ -572,6 +569,10 @@ export class CodexSecurity {
options.auth,
modelProvider,
);
if (authentication.method !== "stored_credentials") {
await releaseCredentialHome?.();
releaseCredentialHome = null;
}
notifyObserver(
"onAuthentication",
options.onAuthentication,
Expand Down Expand Up @@ -941,12 +942,15 @@ export class CodexSecurity {
CODEX_HOME: runtime.codexHome,
...runtimePaths,
};
const sdkCodexConfig = scanPreflightCodexConfig(effectiveConfig);
delete sdkCodexConfig["projects"];
const codex = this.#dependencies.createCodex({
...(externalProvider !== null || apiKey === null ? {} : { apiKey }),
env: definedEnvironment(
selectedScanEnvironment(environment, "chatgpt"),
),
config: {
...(sdkCodexConfig as NonNullable<CodexOptions["config"]>),
default_permissions: SCAN_PERMISSION_PROFILE,
allow_login_shell: false,
},
Expand Down Expand Up @@ -1379,23 +1383,7 @@ export class CodexSecurity {
modelProvider?: unknown,
): Promise<PreparedRuntime> {
this.#requireOpen();
if (this.#runtime !== null) {
const usePersistentCredentials =
scanAuthentication(this.#dependencies.environment, auth, modelProvider)
.method === "stored_credentials";
if (
this.#dependencies.prepareRuntime !== undefined ||
this.#runtime.persistentCredentialHome === undefined ||
this.#runtime.persistentCredentialHome === usePersistentCredentials
) {
return this.#runtime;
}
await this.#cleanupRuntime(this.#runtime);
this.#runtime = null;
this.#runtimePromise = null;
this.#runtimeCredentialSource = null;
this.#requireOpen();
}
if (this.#runtime !== null) return this.#runtime;
if (this.#runtimePromise === null) {
const runtimePromise = this.#prepareRuntime(
signal ?? this.#abortController.signal,
Expand Down Expand Up @@ -1522,15 +1510,10 @@ export class CodexSecurity {
auth,
modelProvider,
);
const persistentCredentialHome =
scanAuthentication(this.#dependencies.environment, auth, modelProvider)
.method === "stored_credentials";
const codexHome = persistentCredentialHome
? await prepareCodexSecurityCredentialHome(
processEnvironment,
validateLocation,
)
: await createIsolatedHome(temporaryRoot, validateLocation);
const codexHome =
validateLocation === undefined
? await prepareCodexSecurityCredentialHome(processEnvironment)
: await realpath(codexSecurityCredentialHome(processEnvironment));
let bootstrapWorkspace: string | undefined;
try {
throwIfAborted(signal);
Expand All @@ -1555,7 +1538,7 @@ export class CodexSecurity {
scanRuntimeCodexConfig(
mergedConfig,
codexSecurityStateDirectory(processEnvironment),
persistentCredentialHome ? codexHome : undefined,
codexHome,
),
);
await writeCodexConfig(join(codexHome, "config.toml"), codexConfig);
Expand All @@ -1580,7 +1563,7 @@ export class CodexSecurity {
);
return {
codexHome,
persistentCredentialHome,
persistentCredentialHome: true,
bootstrapWorkspace,
configPath,
plugin,
Expand All @@ -1594,20 +1577,16 @@ export class CodexSecurity {
effectiveConfig: mergedConfig,
};
} catch (error) {
const cleanupResults = await Promise.allSettled(
[bootstrapWorkspace, persistentCredentialHome ? undefined : codexHome]
.filter((path): path is string => path !== undefined)
.map((path) => cleanupSdkDirectory(path)),
);
const cleanupFailures = cleanupResults.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (cleanupFailures.length > 0) {
throw new AggregateError(
[error, ...cleanupFailures],
"Codex Security runtime preparation failed and its isolated runtime could not be cleaned up.",
{ cause: error },
);
if (bootstrapWorkspace !== undefined) {
try {
await cleanupSdkDirectory(bootstrapWorkspace);
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Codex Security runtime preparation failed and its isolated runtime could not be cleaned up.",
{ cause: error },
);
}
}
throw error;
}
Expand Down
192 changes: 185 additions & 7 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3692,6 +3692,186 @@ describe("CodexSecurity orchestration", () => {
expect(runtimeHomes).toEqual([credentialHome, credentialHome]);
});

test.each([
["OpenAI", undefined, "OPENAI_API_KEY", "gpt-5.6-sol", undefined],
...EXTERNAL_PROVIDER_CASES,
] as const)(
"retains %s scan sessions in the managed Codex home",
async (_name, provider, apiKey, model, providerConfig) => {
const root = await temporaryDirectory();
const repository = join(root, "repository");
const stateDirectory = join(root, "state");
const configuredStateDirectory =
provider === "openrouter" ? join(root, "linked-state") : stateDirectory;
const codexHome = join(stateDirectory, "codex-home");
const scanDir = join(root, "scan");
await mkdir(repository);
await mkdir(scanDir, { mode: 0o700 });
if (configuredStateDirectory !== stateDirectory) {
await mkdir(stateDirectory, { mode: 0o700 });
await symlink(
stateDirectory,
configuredStateDirectory,
process.platform === "win32" ? "junction" : "dir",
);
}
const client = new TestClient(
{
pluginPath: PLUGIN_ROOT,
codexOverrides: {
model,
...(provider === undefined
? {}
: {
model_provider: provider,
model_providers: { [provider]: providerConfig },
}),
},
},
{
environment: {
CODEX_SECURITY_STATE_DIR: configuredStateDirectory,
[apiKey]: "synthetic-transient-key",
},
resolvePluginPython: async () => "/managed/python",
prepareOutputDir: async () => scanDir,
repositoryRevision: async () => "deadbeef",
createCodex: (options: CodexOptions) => ({
startThread: () => ({
id: null,
async runStreamed() {
expect(options.env?.["CODEX_HOME"]).toBe(codexHome);
expect(options.apiKey).toBe(
provider === undefined
? "synthetic-transient-key"
: undefined,
);
await writeUsageSession(codexHome, "persistent-thread", {
input_tokens: 1,
});
throw new Error("persistent session recorded");
},
}),
}),
},
);

try {
await expect(client.run(repository)).rejects.toThrow(
"persistent session recorded",
);
} finally {
await client.close();
}

expect(
existsSync(
join(
codexHome,
"sessions",
"2026",
"07",
"26",
"rollout-persistent-thread.jsonl",
),
),
).toBe(true);
expect(existsSync(join(codexHome, "auth.json"))).toBe(false);
expect(
await readFile(join(codexHome, "config.toml"), "utf8"),
).not.toContain("synthetic-transient-key");
},
);

test("runs API-key scans in parallel through the same managed home", async () => {
const root = await temporaryDirectory();
const repository = join(root, "repository");
const stateDirectory = join(root, "state");
const codexHome = join(stateDirectory, "codex-home");
await mkdir(repository);
let scansStarted = 0;
let releaseScans!: () => void;
const concurrentScans = new Promise<void>((resolve) => {
releaseScans = resolve;
});

const clients = await Promise.all(
[
["OPENAI_API_KEY", "gpt-5.6-sol", undefined],
["OPENROUTER_API_KEY", "anthropic/claude-sonnet-4.5", "openrouter"],
].map(async ([apiKey, model, provider], index) => {
const scanDir = join(root, `parallel-api-key-scan-${index}`);
await mkdir(scanDir, { mode: 0o700 });
return new TestClient(
{
pluginPath: PLUGIN_ROOT,
codexOverrides: {
model,
...(provider === undefined
? {}
: {
model_provider: provider,
model_providers: {
[provider]: OPENROUTER_CODEX_PROVIDER,
},
}),
},
},
{
environment: {
CODEX_SECURITY_STATE_DIR: stateDirectory,
[apiKey!]: `synthetic-key-${index}`,
},
resolvePluginPython: async () => "/managed/python",
prepareOutputDir: async () => scanDir,
repositoryRevision: async () => "deadbeef",
createCodex: (options: CodexOptions) => {
expect(options.env?.["CODEX_HOME"]).toBe(codexHome);
expect(options.config).toMatchObject({
model,
...(provider === undefined
? {}
: {
model_provider: provider,
model_providers: {
[provider]: OPENROUTER_CODEX_PROVIDER,
},
}),
});
return {
startThread: () => ({
id: null,
async runStreamed() {
if (++scansStarted === 2) releaseScans();
await concurrentScans;
throw new Error("parallel API-key scan reached");
},
}),
};
},
},
);
}),
);

try {
const results = await Promise.allSettled(
clients.map((client) => client.run(repository)),
);
for (const result of results) {
expect(result).toMatchObject({
status: "rejected",
reason: expect.objectContaining({
message: "parallel API-key scan reached",
}),
});
}
expect(scansStarted).toBe(2);
} finally {
await Promise.all(clients.map(async (client) => await client.close()));
}
});

test("serializes parallel scans sharing a managed credential home", async () => {
const root = await temporaryDirectory();
const repository = join(root, "repository");
Expand Down Expand Up @@ -4528,7 +4708,7 @@ if (process.argv.slice(2).join(" ") !== "login status") {
}
});

test("recreates isolated and managed runtimes when scan authentication changes", async () => {
test("reuses the managed runtime when scan authentication changes", async () => {
const root = await temporaryDirectory();
const repository = join(root, "repository");
const ambientHome = join(root, "ambient-codex-home");
Expand Down Expand Up @@ -4566,10 +4746,9 @@ if (process.argv.slice(2).join(" ") !== "login status") {
await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow(
"authentication-selected scan reached",
);
const firstIsolatedHome = runs[0]?.home;
expect(firstIsolatedHome).toBeDefined();
expect(firstIsolatedHome).not.toBe(dedicatedHome);
expect(runs[0]?.home).toBe(dedicatedHome);
expect(runs[0]?.apiKey).toBe("synthetic-transient-key");
expect(existsSync(join(dedicatedHome, "auth.json"))).toBe(false);

await expect(client.run(repository, { auth: "chatgpt" })).rejects.toThrow(
"authentication-selected scan reached",
Expand All @@ -4578,19 +4757,18 @@ if (process.argv.slice(2).join(" ") !== "login status") {
expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe(
ambientAuthentication,
);
expect(existsSync(firstIsolatedHome!)).toBe(false);

await expect(client.run(repository, { auth: "api-key" })).rejects.toThrow(
"authentication-selected scan reached",
);
expect(runs[2]?.home).not.toBe(dedicatedHome);
expect(runs[2]?.home).toBe(dedicatedHome);
expect(runs[2]?.apiKey).toBe("synthetic-transient-key");
expect(await readFile(join(dedicatedHome, "auth.json"), "utf8")).toBe(
ambientAuthentication,
);
} finally {
await client.close();
}
expect(existsSync(dedicatedHome)).toBe(true);
});

test("does not cache an environment key as reusable file authentication", async () => {
Expand Down
Loading