diff --git a/src/startup.ts b/src/startup.ts index 31b8d8e0..760d1d4e 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,9 +1,16 @@ import { shouldCheckUpdateNoop, getUpdateNoopStatus } from "./agent/utils.js"; +import { readCodexTokensFromEnv } from "./agent/openai-chatgpt-oauth.js"; import type { CliCommand } from "./commands.js"; import { + OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, + OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, + OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, getMissingProviderEnvKey, + getProviderApiKeyEnvKey, getProviderCredentialHint, + providerUsesOAuth, resolveConfiguredProvider, + type OpenWikiProvider, } from "./constants.js"; type ResolveStartupCommandOptions = { @@ -38,7 +45,10 @@ export async function resolveStartupCommand( (command.print || !isStdinTTY) ) { const provider = resolveConfiguredProvider(); - const missingEnvKey = getMissingProviderEnvKey(provider); + const missingEnvKey = getMissingNonInteractiveProviderEnvKey( + provider, + process.env, + ); if (missingEnvKey) { if ( @@ -56,7 +66,7 @@ export async function resolveStartupCommand( return { kind: "error", exitCode: 1, - message: `${missingEnvKey} is required for non-interactive runs. Run openwiki in an interactive terminal to save credentials.${ + message: `${formatCredentialRequirement(provider, missingEnvKey)} is required for non-interactive runs. Run openwiki in an interactive terminal to save credentials.${ hint ? ` ${hint}` : "" }`, }; @@ -79,6 +89,30 @@ export async function resolveStartupCommand( return command; } +function getMissingNonInteractiveProviderEnvKey( + provider: OpenWikiProvider, + env: NodeJS.ProcessEnv, +): string | null { + if (!providerUsesOAuth(provider)) { + return getMissingProviderEnvKey(provider, env); + } + + return readCodexTokensFromEnv(env) === null + ? (getProviderApiKeyEnvKey(provider) ?? "ChatGPT OAuth token set") + : null; +} + +function formatCredentialRequirement( + provider: OpenWikiProvider, + apiKeyEnvKey: string, +): string { + if (!providerUsesOAuth(provider)) { + return apiKeyEnvKey; + } + + return `A complete ChatGPT OAuth token set (${apiKeyEnvKey}, ${OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY}, ${OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY}, ${OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY})`; +} + async function canSkipCleanUpdateBeforeCredentials( command: Extract, cwd: string, diff --git a/test/startup.test.ts b/test/startup.test.ts index 2725a0b2..7f2650a5 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -6,10 +6,27 @@ import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { resolveStartupCommand } from "../src/startup.ts"; import type { CliCommand } from "../src/commands.ts"; +import { + OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY, + OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, + OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, + OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, + OPENROUTER_API_KEY_ENV_KEY, + OPENWIKI_PROVIDER_ENV_KEY, +} from "../src/constants.ts"; const execFileAsync = promisify(execFile); -const originalProvider = process.env.OPENWIKI_PROVIDER; -const originalOpenRouterKey = process.env.OPENROUTER_API_KEY; +const MANAGED_ENV_KEYS = [ + OPENWIKI_PROVIDER_ENV_KEY, + OPENROUTER_API_KEY_ENV_KEY, + OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY, + OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, + OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, + OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, +] as const; +const originalEnv = new Map( + MANAGED_ENV_KEYS.map((key) => [key, process.env[key]]), +); async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd }); @@ -63,18 +80,34 @@ function updatePrintCommand( }; } +function setEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function storeChatGptTokens(expiresAtMs = Date.now() + 60 * 60 * 1000): void { + setEnv(OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY, "access-token"); + setEnv(OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, "refresh-token"); + setEnv(OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, String(expiresAtMs)); + setEnv(OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, "acct_1"); +} + beforeEach(() => { - process.env.OPENWIKI_PROVIDER = "openrouter"; - delete process.env.OPENROUTER_API_KEY; + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "openrouter"; + delete process.env[OPENROUTER_API_KEY_ENV_KEY]; + delete process.env[OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY]; + delete process.env[OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY]; + delete process.env[OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY]; + delete process.env[OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY]; }); afterEach(() => { - if (originalProvider === undefined) delete process.env.OPENWIKI_PROVIDER; - else process.env.OPENWIKI_PROVIDER = originalProvider; - - if (originalOpenRouterKey === undefined) - delete process.env.OPENROUTER_API_KEY; - else process.env.OPENROUTER_API_KEY = originalOpenRouterKey; + for (const key of MANAGED_ENV_KEYS) { + setEnv(key, originalEnv.get(key)); + } }); describe("resolveStartupCommand", () => { @@ -152,4 +185,44 @@ describe("resolveStartupCommand", () => { expect(result.message).toContain("OPENROUTER_API_KEY is required"); } }); + + test("rejects non-interactive ChatGPT OAuth startup with incomplete tokens", async () => { + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "openai-chatgpt"; + process.env[OPENAI_CHATGPT_ACCESS_TOKEN_ENV_KEY] = "access-token"; + process.env[OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY] = String( + Date.now() + 60 * 60 * 1000, + ); + + const result = await resolveStartupCommand( + updatePrintCommand({ userMessage: "refresh API docs" }), + { isStdinTTY: false }, + ); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toContain("ChatGPT OAuth token set"); + expect(result.message).toContain(OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY); + expect(result.message).toContain(OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY); + } + }); + + test("allows non-interactive ChatGPT OAuth startup with complete tokens", async () => { + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "openai-chatgpt"; + storeChatGptTokens(); + + const command = updatePrintCommand({ userMessage: "refresh API docs" }); + const result = await resolveStartupCommand(command, { isStdinTTY: false }); + + expect(result).toBe(command); + }); + + test("leaves expired complete ChatGPT OAuth tokens to the agent refresh path", async () => { + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "openai-chatgpt"; + storeChatGptTokens(Date.now() - 60 * 1000); + + const command = updatePrintCommand({ userMessage: "refresh API docs" }); + const result = await resolveStartupCommand(command, { isStdinTTY: false }); + + expect(result).toBe(command); + }); });