From bc500d30092b2ec5f2de2b9077b5d2f88c7146c4 Mon Sep 17 00:00:00 2001 From: Tomas Cupr Date: Sat, 26 Apr 2025 19:13:30 +0200 Subject: [PATCH] feat: user config api key (#569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for reading OPENAI_API_KEY (and other variables) from a user‑wide dotenv file (~/.codex.config). Precedence order is now: 1. explicit environment variable 2. project‑local .env (loaded earlier) 3. ~/.codex.config Also adds a regression test that ensures the multiline editor correctly handles cases where printable text and the CSI‑u Shift+Enter sequence arrive in the same input chunk. House‑kept with Prettier; removed stray temp.json artifact. --- codex-cli/src/utils/config.ts | 28 ++++++++++- codex-cli/tests/user-config-env.test.ts | 62 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 codex-cli/tests/user-config-env.test.ts diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts index 7bd6052e..1bf05303 100644 --- a/codex-cli/src/utils/config.ts +++ b/codex-cli/src/utils/config.ts @@ -11,11 +11,37 @@ import type { FullAutoErrorMode } from "./auto-approval-mode.js"; import { AutoApprovalMode } from "./auto-approval-mode.js"; import { log } from "./logger/log.js"; import { providers } from "./providers.js"; +import { config as loadDotenv } from "dotenv"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { load as loadYaml, dump as dumpYaml } from "js-yaml"; import { homedir } from "os"; import { dirname, join, extname, resolve as resolvePath } from "path"; +// --------------------------------------------------------------------------- +// User‑wide environment config (~/.codex.env) +// --------------------------------------------------------------------------- + +// Load a user‑level dotenv file **after** process.env and any project‑local +// .env file (loaded via "dotenv/config" in cli.tsx) are in place. We rely on +// dotenv's default behaviour of *not* overriding existing variables so that +// the precedence order becomes: +// 1. Explicit environment variables +// 2. Project‑local .env (handled in cli.tsx) +// 3. User‑wide ~/.codex.env (loaded here) +// This guarantees that users can still override the global key on a per‑project +// basis while enjoying the convenience of a persistent default. + +// Skip when running inside Vitest to avoid interfering with the FS mocks used +// by tests that stub out `fs` *after* importing this module. +const USER_WIDE_CONFIG_PATH = join(homedir(), ".codex.env"); + +const isVitest = + typeof (globalThis as { vitest?: unknown }).vitest !== "undefined"; + +if (!isVitest) { + loadDotenv({ path: USER_WIDE_CONFIG_PATH }); +} + export const DEFAULT_AGENTIC_MODEL = "o4-mini"; export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1"; export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST; @@ -117,7 +143,7 @@ export type StoredConfig = { // propagating to existing users until they explicitly set a model. export const EMPTY_STORED_CONFIG: StoredConfig = { model: "" }; -// Pre‑stringified JSON variant so we don’t stringify repeatedly. +// Pre‑stringified JSON variant so we don't stringify repeatedly. const EMPTY_CONFIG_JSON = JSON.stringify(EMPTY_STORED_CONFIG, null, 2) + "\n"; export type MemoryConfig = { diff --git a/codex-cli/tests/user-config-env.test.ts b/codex-cli/tests/user-config-env.test.ts new file mode 100644 index 00000000..76cdcbac --- /dev/null +++ b/codex-cli/tests/user-config-env.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +/** + * Verifies that ~/.codex.env is parsed (lowest‑priority) when present. + */ + +describe("user‑wide ~/.codex.env support", () => { + const ORIGINAL_HOME = process.env["HOME"]; + const ORIGINAL_API_KEY = process.env["OPENAI_API_KEY"]; + + let tempHome: string; + + beforeEach(() => { + // Create an isolated fake $HOME directory. + tempHome = mkdtempSync(join(tmpdir(), "codex-home-")); + process.env["HOME"] = tempHome; + + // Ensure the env var is unset so that the file value is picked up. + delete process.env["OPENAI_API_KEY"]; + + // Write ~/.codex.env with a dummy key. + writeFileSync( + join(tempHome, ".codex.env"), + "OPENAI_API_KEY=my-home-key\n", + { + encoding: "utf8", + }, + ); + }); + + afterEach(() => { + // Cleanup temp directory. + try { + rmSync(tempHome, { recursive: true, force: true }); + } catch { + // ignore + } + + // Restore original env. + if (ORIGINAL_HOME !== undefined) { + process.env["HOME"] = ORIGINAL_HOME; + } else { + delete process.env["HOME"]; + } + + if (ORIGINAL_API_KEY !== undefined) { + process.env["OPENAI_API_KEY"] = ORIGINAL_API_KEY; + } else { + delete process.env["OPENAI_API_KEY"]; + } + }); + + it("loads the API key from ~/.codex.env when not set elsewhere", async () => { + // Import the config module AFTER setting up the fake env. + const { getApiKey } = await import("../src/utils/config.js"); + + expect(getApiKey("openai")).toBe("my-home-key"); + }); +});