feat: user config api key (#569)
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.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
62
codex-cli/tests/user-config-env.test.ts
Normal file
62
codex-cli/tests/user-config-env.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user