Skip to content
Open
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
38 changes: 36 additions & 2 deletions src/startup.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 (
Expand All @@ -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}` : ""
}`,
};
Expand All @@ -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<CliCommand, { kind: "run" }>,
cwd: string,
Expand Down
93 changes: 83 additions & 10 deletions test/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});