diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 685d7aefe..3226c514e 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1847,6 +1847,14 @@ export class CodexSecurity { } const authentication = await this.#authentication(); this.#requireOpen(); + const ambientHome = + environmentValue(this.#dependencies.environment, "CODEX_HOME") ?? + join(homedir(), ".codex"); + await initialCredentialsAvailable( + this.#dependencies.environment, + ambientHome, + authentication.codexHome, + ); return await accountStatus( this.#codexCommand(), authentication.environment, diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 83c48ad74..f97806ea0 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -27,7 +27,7 @@ import { rm, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { basename, dirname, @@ -53,6 +53,7 @@ import { createSecurityInternal, environmentValue, formatEnvironmentVariableRemovalGuidance, + initialCredentialsAvailable, listRepositoryFindings, SCAN_AUTH_MODES, scanAuthentication, @@ -4267,6 +4268,16 @@ export async function main( : await prepareCodexSecurityCredentialHome( dependencies.environment, ); + if (args.action === "status" && existsSync(credentialHome)) { + const ambientHome = + environmentValue(dependencies.environment, "CODEX_HOME") ?? + join(homedir(), ".codex"); + await initialCredentialsAvailable( + dependencies.environment, + ambientHome, + credentialHome, + ); + } const authenticationEnvironment = { ...dependencies.environment, CODEX_HOME: credentialHome, diff --git a/sdk/typescript/tests-ts/api-credentials.test.ts b/sdk/typescript/tests-ts/api-credentials.test.ts index ae54415ae..fb4cb089e 100644 --- a/sdk/typescript/tests-ts/api-credentials.test.ts +++ b/sdk/typescript/tests-ts/api-credentials.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import type { CodexOptions } from "@openai/codex-sdk"; import { afterEach, describe, expect, test } from "bun:test"; import { parse as parseToml } from "smol-toml"; @@ -29,7 +30,10 @@ describe("CodexSecurity orchestration", () => { await mkdir(scanDir, { mode: 0o700 }); await writeFile(join(ambientHome, "auth.json"), "{}\n"); const interpreter = - Bun.which("python3") ?? Bun.which("python") ?? Bun.which("py"); + process.env["PYTHON"] ?? + Bun.which("python") ?? + Bun.which("py") ?? + Bun.which("python3"); expect(interpreter).not.toBeNull(); let capturedConfigPath: string | undefined; let capturedCodexHome: string | undefined; @@ -473,4 +477,61 @@ describe("CodexSecurity orchestration", () => { ), ).resolves.toBe(true); }); + + test("recognizes ambient credentials during account() on a fresh instance", async () => { + const root = await temporaryDirectory(); + const ambientHome = join(root, "ambient-home"); + const stateDir = join(root, "state"); + const script = join(root, "codex.mjs"); + await mkdir(ambientHome); + await mkdir(stateDir, { mode: 0o700 }); + await writeFile( + join(ambientHome, "auth.json"), + '{"auth_mode":"chatgpt"}\n', + ); + await writeFile( + script, + ` +import { existsSync } from "node:fs"; +import { basename, join } from "node:path"; + +const args = [basename(process.argv[1]), ...process.argv.slice(2)]; +if (args.join(" ") === "login status") { + const codexHome = process.env.CODEX_HOME; + if (codexHome && existsSync(join(codexHome, "auth.json"))) { + console.log("Logged in using ChatGPT"); + process.exitCode = 0; + } else { + console.log("Not logged in"); + process.exitCode = 1; + } +} +process.exit(process.exitCode ?? 0); +`, + ); + const client = new TestClient( + { pluginPath: PLUGIN_ROOT }, + { + environment: { + ...process.env, + NODE_OPTIONS: `--import=${pathToFileURL(script).href}`, + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDir, + }, + resolveCodexCommand: () => ({ + command: execFileSync("node", ["-p", "process.execPath"], { + encoding: "utf8", + }).trim(), + }), + }, + ); + try { + const status = await client.account(); + expect(status.authenticated).toBe(true); + expect(status.details).toContain("Logged in using ChatGPT"); + expect(existsSync(join(stateDir, "codex-home", "auth.json"))).toBe(true); + } finally { + await client.close(); + } + }); }); diff --git a/sdk/typescript/tests-ts/cli-authentication.test.ts b/sdk/typescript/tests-ts/cli-authentication.test.ts index a921e46a5..38fa2b49e 100644 --- a/sdk/typescript/tests-ts/cli-authentication.test.ts +++ b/sdk/typescript/tests-ts/cli-authentication.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, @@ -16,6 +17,7 @@ import { CodexSecurityError, type ScanOptions } from "../src/index.js"; import { codexSecurityCredentialAllowsAmbientImport, prepareCodexSecurityCredentialHome, + setCodexSecurityCredentialLogout, } from "../src/runtime.js"; import { capture, @@ -1067,4 +1069,78 @@ describe("CLI authentication", () => { expect(JSON.parse(stdout.text())).toMatchObject({ authentication }); expect(`${stdout.text()}${stderr.text()}`).not.toContain("synthetic"); }); + + test("recognizes existing ambient Codex authentication on a fresh state directory during login status", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-cli-ambient-auth-")), + ); + try { + const ambientHome = join(root, "ambient-codex"); + await mkdir(ambientHome, { mode: 0o700 }); + await writeFile( + join(ambientHome, "auth.json"), + '{"auth_mode":"chatgpt"}\n', + ); + + const stdout = capture(); + const stderr = capture(); + let forwardedHome: string | undefined; + const deps = dependencies({ + environment: { + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }); + deps.runCodex = async (_args, _output, authEnvironment) => { + forwardedHome = authEnvironment?.["CODEX_HOME"]; + return 0; + }; + + expect( + await main(["login", "status"], stdout.stream, stderr.stream, deps), + ).toBe(0); + expect(forwardedHome).toBe(join(stateDirectory, "codex-home")); + expect(existsSync(join(stateDirectory, "codex-home", "auth.json"))).toBe( + true, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("does not import ambient Codex authentication during login status after explicit logout", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "codex-security-cli-ambient-logout-")), + ); + try { + const ambientHome = join(root, "ambient-codex"); + await mkdir(ambientHome, { mode: 0o700 }); + await writeFile( + join(ambientHome, "auth.json"), + '{"auth_mode":"chatgpt"}\n', + ); + + const credentialHome = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: stateDirectory, + }); + await setCodexSecurityCredentialLogout(credentialHome, true); + + const stdout = capture(); + const stderr = capture(); + const deps = dependencies({ + environment: { + CODEX_HOME: ambientHome, + CODEX_SECURITY_STATE_DIR: stateDirectory, + }, + }); + deps.runCodex = async () => 0; + + expect( + await main(["login", "status"], stdout.stream, stderr.stream, deps), + ).toBe(0); + expect(existsSync(join(credentialHome, "auth.json"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); });