From dcc3ea9e2014b0477052734a3a88064ad65171fe Mon Sep 17 00:00:00 2001 From: mojo-opencode Date: Sun, 22 Feb 2026 14:50:44 +0000 Subject: [PATCH 01/10] feat(cli): add PR1 provider auth capability plumbing --- .../src/lib/utils/__tests__/provider.test.ts | 54 ++++++++++++++++++- apps/cli/src/lib/utils/provider.ts | 25 +++++++++ apps/cli/src/types/types.ts | 1 + 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/lib/utils/__tests__/provider.test.ts b/apps/cli/src/lib/utils/__tests__/provider.test.ts index 70d8a2a5557..632b315c664 100644 --- a/apps/cli/src/lib/utils/__tests__/provider.test.ts +++ b/apps/cli/src/lib/utils/__tests__/provider.test.ts @@ -1,4 +1,51 @@ -import { getApiKeyFromEnv } from "../provider.js" +import { getApiKeyFromEnv, getProviderAuthMode, providerRequiresApiKey, providerSupportsOAuth } from "../provider.js" + +describe("getProviderAuthMode", () => { + it("should return oauth for openai-codex", () => { + expect(getProviderAuthMode("openai-codex")).toBe("oauth") + }) + + it("should return roo-token for roo", () => { + expect(getProviderAuthMode("roo")).toBe("roo-token") + }) + + it.each(["anthropic", "openai-native", "gemini", "openrouter", "vercel-ai-gateway"] as const)( + "should return api-key for %s", + (provider) => { + expect(getProviderAuthMode(provider)).toBe("api-key") + }, + ) +}) + +describe("providerRequiresApiKey", () => { + it.each(["anthropic", "openai-native", "gemini", "openrouter", "vercel-ai-gateway"] as const)( + "should require API key for %s", + (provider) => { + expect(providerRequiresApiKey(provider)).toBe(true) + }, + ) + + it("should not require API key for roo", () => { + expect(providerRequiresApiKey("roo")).toBe(false) + }) + + it("should not require API key for openai-codex", () => { + expect(providerRequiresApiKey("openai-codex")).toBe(false) + }) +}) + +describe("providerSupportsOAuth", () => { + it("should support OAuth for openai-codex", () => { + expect(providerSupportsOAuth("openai-codex")).toBe(true) + }) + + it.each(["anthropic", "openai-native", "gemini", "openrouter", "vercel-ai-gateway", "roo"] as const)( + "should not support OAuth for %s", + (provider) => { + expect(providerSupportsOAuth(provider)).toBe(false) + }, + ) +}) describe("getApiKeyFromEnv", () => { const originalEnv = process.env @@ -27,6 +74,11 @@ describe("getApiKeyFromEnv", () => { expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key") }) + it("should return API key from environment variable for openai-codex", () => { + process.env.OPENAI_API_KEY = "test-openai-codex-key" + expect(getApiKeyFromEnv("openai-codex")).toBe("test-openai-codex-key") + }) + it("should return undefined when API key is not set", () => { delete process.env.ANTHROPIC_API_KEY expect(getApiKeyFromEnv("anthropic")).toBeUndefined() diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1b..f6b04c64d88 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -2,9 +2,31 @@ import { RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" +export type ProviderAuthMode = "api-key" | "oauth" | "roo-token" + +export function getProviderAuthMode(provider: SupportedProvider): ProviderAuthMode { + switch (provider) { + case "openai-codex": + return "oauth" + case "roo": + return "roo-token" + default: + return "api-key" + } +} + +export function providerRequiresApiKey(provider: SupportedProvider): boolean { + return getProviderAuthMode(provider) === "api-key" +} + +export function providerSupportsOAuth(provider: SupportedProvider): boolean { + return getProviderAuthMode(provider) === "oauth" +} + const envVarMap: Record = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", + "openai-codex": "OPENAI_API_KEY", gemini: "GOOGLE_API_KEY", openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", @@ -36,6 +58,9 @@ export function getProviderSettings( if (apiKey) config.openAiNativeApiKey = apiKey if (model) config.apiModelId = model break + case "openai-codex": + if (model) config.apiModelId = model + break case "gemini": if (apiKey) config.geminiApiKey = apiKey if (model) config.apiModelId = model diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index fbd132bfdce..8ee5a9c6fd9 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -4,6 +4,7 @@ import type { OutputFormat } from "./json-events.js" export const supportedProviders = [ "anthropic", "openai-native", + "openai-codex", "gemini", "openrouter", "vercel-ai-gateway", From d94e66c4b253cef4f6c1a2ed1a43e1fd7e7383de Mon Sep 17 00:00:00 2001 From: mojo-opencode Date: Sun, 22 Feb 2026 15:01:59 +0000 Subject: [PATCH 02/10] feat(cli): branch run auth by provider capability --- .../src/commands/cli/__tests__/run.test.ts | 127 +++++++++++++++ apps/cli/src/commands/cli/run.ts | 149 ++++++++++++------ 2 files changed, 231 insertions(+), 45 deletions(-) diff --git a/apps/cli/src/commands/cli/__tests__/run.test.ts b/apps/cli/src/commands/cli/__tests__/run.test.ts index 7b7693a39cd..3d5b7150aa4 100644 --- a/apps/cli/src/commands/cli/__tests__/run.test.ts +++ b/apps/cli/src/commands/cli/__tests__/run.test.ts @@ -2,6 +2,133 @@ import fs from "fs" import path from "path" import os from "os" +import type { User } from "@/lib/sdk/index.js" + +import * as sdk from "@/lib/sdk/index.js" +import * as storage from "@/lib/storage/index.js" + +import { assertAuthReady, resolveProviderAuthentication } from "../run.js" + +describe("run auth helpers", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + vi.restoreAllMocks() + vi.spyOn(storage, "loadProviderApiKey").mockResolvedValue(null) + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("marks openai-codex auth as OAuth bootstrap without API key", async () => { + const auth = await resolveProviderAuthentication({ + provider: "openai-codex", + rooToken: null, + settings: {}, + interactive: false, + }) + + expect(auth.needsOAuthBootstrap).toBe(true) + expect(auth.apiKey).toBeUndefined() + }) + + it("keeps openai-native API key flow via OPENAI_API_KEY", async () => { + process.env.OPENAI_API_KEY = "env-openai-key" + + const auth = await resolveProviderAuthentication({ + provider: "openai-native", + rooToken: null, + settings: {}, + interactive: false, + }) + + expect(auth.needsOAuthBootstrap).toBe(false) + expect(auth.apiKey).toBe("env-openai-key") + }) + + it("authenticates roo provider with valid cloud token", async () => { + const rooUser = { id: "user_1" } as User + + vi.spyOn(sdk, "createClient").mockReturnValue({ + auth: { + me: { + query: vi.fn().mockResolvedValue({ type: "user", user: rooUser }), + }, + }, + } as ReturnType) + + const auth = await resolveProviderAuthentication({ + provider: "roo", + rooToken: "valid-token", + settings: {}, + interactive: true, + }) + + expect(auth.apiKey).toBe("valid-token") + expect(auth.rooUser).toEqual(rooUser) + expect(auth.invalidRooToken).toBeUndefined() + }) + + it("falls back when roo token is invalid", async () => { + vi.spyOn(sdk, "createClient").mockReturnValue({ + auth: { + me: { + query: vi.fn().mockRejectedValue(new Error("invalid token")), + }, + }, + } as ReturnType) + vi.spyOn(storage, "loadProviderApiKey").mockResolvedValue("saved-roo-api-key") + + const auth = await resolveProviderAuthentication({ + provider: "roo", + rooToken: "invalid-token", + settings: {}, + interactive: true, + }) + + expect(auth.invalidRooToken).toBe(true) + expect(auth.apiKey).toBe("saved-roo-api-key") + }) + + it("does not fail auth readiness for openai-codex without API key", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`) + }) as never) + + await expect( + assertAuthReady({ + provider: "openai-codex", + auth: { needsOAuthBootstrap: true }, + interactive: false, + }), + ).resolves.toBeUndefined() + expect(exitSpy).not.toHaveBeenCalled() + }) + + it("still fails API-key providers without API key", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`) + }) as never) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect( + assertAuthReady({ + provider: "openai-native", + auth: {}, + interactive: false, + }), + ).rejects.toThrow("exit:1") + + expect(exitSpy).toHaveBeenCalledWith(1) + expect(errorSpy).toHaveBeenCalledWith( + "[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.", + ) + expect(errorSpy).toHaveBeenCalledWith("[CLI] For openai-native, set OPENAI_API_KEY") + }) +}) + describe("run command --prompt-file option", () => { let tempDir: string let promptFilePath: string diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index b72e4e72834..eed9e5189c0 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -7,9 +7,11 @@ import { createElement } from "react" import { setLogger } from "@roo-code/vscode-shim" import { + CliSettings, FlagOptions, isSupportedProvider, OnboardingProviderChoice, + SupportedProvider, supportedProviders, DEFAULT_FLAGS, REASONING_EFFORTS, @@ -20,8 +22,8 @@ import { isValidOutputFormat } from "@/types/json-events.js" import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" -import { loadToken, loadSettings } from "@/lib/storage/index.js" -import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { loadProviderApiKey, loadToken, loadSettings } from "@/lib/storage/index.js" +import { getEnvVarName, getApiKeyFromEnv, getProviderAuthMode } from "@/lib/utils/provider.js" import { runOnboarding } from "@/lib/utils/onboarding.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" import { VERSION } from "@/lib/utils/version.js" @@ -31,6 +33,89 @@ import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) +type ProviderAuthentication = { + apiKey?: string + rooUser?: NonNullable + needsOAuthBootstrap?: boolean + invalidRooToken?: boolean +} + +export async function resolveProviderAuthentication(params: { + provider: SupportedProvider + flagApiKey?: string + preloadedApiKey?: string + rooToken: string | null + settings: CliSettings + host?: ExtensionHost + interactive: boolean +}): Promise { + const { provider, flagApiKey, preloadedApiKey, rooToken } = params + + const authMode = getProviderAuthMode(provider) + const auth: ProviderAuthentication = { + needsOAuthBootstrap: authMode === "oauth", + } + + if (provider === "roo" && rooToken) { + try { + const client = createClient({ url: SDK_BASE_URL, authToken: rooToken }) + const me = await client.auth.me.query() + + if (me?.type !== "user") { + throw new Error("Invalid token") + } + + auth.apiKey = rooToken + auth.rooUser = me.user + } catch { + auth.invalidRooToken = true + } + } + + auth.apiKey = + auth.apiKey || + preloadedApiKey || + flagApiKey || + (await loadProviderApiKey(provider)) || + getApiKeyFromEnv(provider) + + return auth +} + +export async function assertAuthReady(params: { + provider: SupportedProvider + auth: ProviderAuthentication + interactive: boolean +}): Promise { + const { provider, auth } = params + const authMode = getProviderAuthMode(provider) + + if (authMode === "oauth") { + return + } + + if (auth.apiKey) { + return + } + + if (authMode === "roo-token") { + if (auth.invalidRooToken) { + console.error("[CLI] Your Roo Code Router token is not valid.") + console.error("[CLI] Please run: roo auth login") + console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.") + } else { + console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.") + console.error("[CLI] Please run: roo auth login") + console.error("[CLI] Or use --api-key to provide your own API key.") + } + } else { + console.error(`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`) + console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`) + } + + process.exit(1) +} + export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -103,33 +188,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } } - if (extensionHostOptions.provider === "roo") { - if (rooToken) { - try { - const client = createClient({ url: SDK_BASE_URL, authToken: rooToken }) - const me = await client.auth.me.query() - - if (me?.type !== "user") { - throw new Error("Invalid token") - } - - extensionHostOptions.apiKey = rooToken - extensionHostOptions.user = me.user - } catch { - // If an explicit API key was provided via flag or env var, fall through - // to the general API key resolution below instead of exiting. - if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) { - console.error("[CLI] Your Roo Code Router token is not valid.") - console.error("[CLI] Please run: roo auth login") - console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.") - process.exit(1) - } - } - } - // If no rooToken, fall through to the general API key resolution below - // which will check flagOptions.apiKey and ROO_API_KEY env var. - } - // Validations // TODO: Validate the API key for the chosen provider. // TODO: Validate the model for the chosen provider. @@ -141,26 +199,27 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.exit(1) } - extensionHostOptions.apiKey = - extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider) + const auth = await resolveProviderAuthentication({ + provider: extensionHostOptions.provider, + flagApiKey: flagOptions.apiKey, + preloadedApiKey: extensionHostOptions.apiKey, + rooToken, + settings, + interactive: isTuiEnabled, + }) - if (!extensionHostOptions.apiKey) { - if (extensionHostOptions.provider === "roo") { - console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.") - console.error("[CLI] Please run: roo auth login") - console.error("[CLI] Or use --api-key to provide your own API key.") - } else { - console.error( - `[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`, - ) - console.error( - `[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`, - ) - } + extensionHostOptions.apiKey = auth.apiKey - process.exit(1) + if (auth.rooUser) { + extensionHostOptions.user = auth.rooUser } + await assertAuthReady({ + provider: extensionHostOptions.provider, + auth, + interactive: isTuiEnabled, + }) + if (!fs.existsSync(extensionHostOptions.workspacePath)) { console.error(`[CLI] Error: Workspace path does not exist: ${extensionHostOptions.workspacePath}`) process.exit(1) From 64d37ffca7e21adce303e5ec9398bb6bae2f1994 Mon Sep 17 00:00:00 2001 From: mojo-opencode Date: Sun, 22 Feb 2026 15:12:21 +0000 Subject: [PATCH 03/10] feat(cli): add openai-codex oauth bridge in extension host --- .../agent/__tests__/extension-client.test.ts | 33 ++++++- .../agent/__tests__/extension-host.test.ts | 89 +++++++++++++++++++ apps/cli/src/agent/extension-client.ts | 16 ++++ apps/cli/src/agent/extension-host.ts | 70 +++++++++++++++ 4 files changed, 206 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/agent/__tests__/extension-client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts index 7a63fe0174c..28037b47037 100644 --- a/apps/cli/src/agent/__tests__/extension-client.test.ts +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -14,8 +14,15 @@ function createMessage(overrides: Partial): ClineMessage { return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides } } -function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage { - return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage +function createStateMessage( + messages: ClineMessage[], + mode?: string, + openAiCodexIsAuthenticated?: boolean, +): ExtensionMessage { + return { + type: "state", + state: { clineMessages: messages, mode, openAiCodexIsAuthenticated }, + } as ExtensionMessage } describe("detectAgentState", () => { @@ -548,6 +555,28 @@ describe("ExtensionClient", () => { expect(client.getCurrentMode()).toBe("architect") }) }) + + describe("Provider auth state", () => { + it("should track OpenAI Codex auth state from extension state messages", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([], "code", true)) + expect(client.getProviderAuthState().openAiCodexIsAuthenticated).toBe(true) + + client.handleMessage(createStateMessage([], "code", false)) + expect(client.getProviderAuthState().openAiCodexIsAuthenticated).toBe(false) + }) + + it("should clear provider auth state on reset", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([], "code", true)) + expect(client.getProviderAuthState().openAiCodexIsAuthenticated).toBe(true) + + client.reset() + expect(client.getProviderAuthState().openAiCodexIsAuthenticated).toBeUndefined() + }) + }) }) describe("Integration", () => { diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 2354e3ab75d..b4b9fa74f0e 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -285,6 +285,60 @@ describe("ExtensionHost", () => { }) }) + describe("ensureOpenAiCodexAuthenticated", () => { + it("should return success for non-openai-codex providers", async () => { + const host = createTestHost({ provider: "openrouter" }) + + await expect(host.ensureOpenAiCodexAuthenticated()).resolves.toEqual({ success: true }) + }) + + it("should return success immediately when already authenticated", async () => { + const host = createTestHost({ provider: "openai-codex" }) + host.markWebviewReady() + + host.client.handleMessage({ + type: "state", + state: { clineMessages: [], openAiCodexIsAuthenticated: true }, + } as ExtensionMessage) + + const emitSpy = vi.spyOn(host, "emit") + + await expect(host.ensureOpenAiCodexAuthenticated()).resolves.toEqual({ success: true }) + expect(emitSpy).not.toHaveBeenCalledWith("webviewMessage", { type: "openAiCodexSignIn" }) + }) + + it("should trigger sign-in and resolve when authentication state becomes true", async () => { + const host = createTestHost({ provider: "openai-codex" }) + host.markWebviewReady() + const emitSpy = vi.spyOn(host, "emit") + emitSpy.mockClear() + + const authPromise = host.ensureOpenAiCodexAuthenticated({ timeoutMs: 500 }) + + setTimeout(() => { + host.emit("extensionWebviewMessage", { + type: "state", + state: { openAiCodexIsAuthenticated: true }, + } as ExtensionMessage) + }, 10) + + await expect(authPromise).resolves.toEqual({ success: true }) + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "openAiCodexSignIn" }) + }) + + it("should return timeout failure when authentication does not complete", async () => { + const host = createTestHost({ provider: "openai-codex" }) + host.markWebviewReady() + + const result = await host.ensureOpenAiCodexAuthenticated({ timeoutMs: 10 }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.reason).toContain("timed out") + } + }) + }) + describe("quiet mode", () => { describe("setupQuietMode", () => { it("should not modify console when integrationTest is true", () => { @@ -484,6 +538,41 @@ describe("ExtensionHost", () => { await expect(taskPromise).resolves.toBeUndefined() }) + + it("should ensure openai-codex authentication before starting a task", async () => { + const host = createTestHost({ provider: "openai-codex" }) + host.markWebviewReady() + + const ensureAuthSpy = vi.spyOn(host, "ensureOpenAiCodexAuthenticated").mockResolvedValue({ success: true }) + const emitSpy = vi.spyOn(host, "emit") + const client = getPrivate(host, "client") as ExtensionClient + + const taskPromise = host.runTask("test prompt") + + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) + + await taskPromise + + expect(ensureAuthSpy).toHaveBeenCalled() + const newTaskCallIndex = emitSpy.mock.calls.findIndex( + (call) => call[0] === "webviewMessage" && (call[1] as WebviewMessage)?.type === "newTask", + ) + expect(newTaskCallIndex).toBeGreaterThanOrEqual(0) + const newTaskCallOrder = emitSpy.mock.invocationCallOrder[newTaskCallIndex] + expect(newTaskCallOrder).toBeDefined() + expect(ensureAuthSpy.mock.invocationCallOrder[0]).toBeLessThan(newTaskCallOrder!) + }) }) describe("initial settings", () => { diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts index c2d77dfdd91..c503ec1ecdb 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -122,6 +122,9 @@ export class ExtensionClient { private store: StateStore private processor: MessageProcessor private emitter: TypedEventEmitter + private providerAuthState: { + openAiCodexIsAuthenticated?: boolean + } = {} private sendMessage: (message: WebviewMessage) => void private debug: boolean @@ -167,6 +170,10 @@ export class ExtensionClient { parsed = message } + if (parsed.type === "state" && parsed.state) { + this.providerAuthState.openAiCodexIsAuthenticated = parsed.state.openAiCodexIsAuthenticated + } + this.processor.processMessage(parsed) } @@ -268,6 +275,14 @@ export class ExtensionClient { return this.store.getCurrentMode() } + getProviderAuthState(): { + openAiCodexIsAuthenticated?: boolean + } { + return { + openAiCodexIsAuthenticated: this.providerAuthState.openAiCodexIsAuthenticated, + } + } + // =========================================================================== // Event Subscriptions - Realtime notifications // =========================================================================== @@ -495,6 +510,7 @@ export class ExtensionClient { */ reset(): void { this.store.reset() + this.providerAuthState = {} this.emitter.removeAllListeners() } diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 4a0e941b4bb..868bc4c4370 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -107,6 +107,9 @@ interface WebviewViewProvider { export interface ExtensionHostInterface extends IExtensionHost { client: ExtensionClient activate(): Promise + ensureOpenAiCodexAuthenticated(options?: { + timeoutMs?: number + }): Promise<{ success: true } | { success: false; reason: string }> runTask(prompt: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise @@ -459,6 +462,14 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // ========================================================================== public async runTask(prompt: string): Promise { + if (this.options.provider === "openai-codex") { + const oauthAuthResult = await this.ensureOpenAiCodexAuthenticated() + + if (!oauthAuthResult.success) { + throw new Error(oauthAuthResult.reason) + } + } + this.sendToExtension({ type: "newTask", text: prompt }) return new Promise((resolve, reject) => { @@ -501,6 +512,65 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac }) } + public async ensureOpenAiCodexAuthenticated(options?: { + timeoutMs?: number + }): Promise<{ success: true } | { success: false; reason: string }> { + if (this.options.provider !== "openai-codex") { + return { success: true } + } + + if (this.client.getProviderAuthState().openAiCodexIsAuthenticated) { + return { success: true } + } + + const timeoutMs = options?.timeoutMs ?? 300_000 + + return await new Promise((resolve) => { + let settled = false + + const cleanup = (timer: NodeJS.Timeout) => { + if (settled) { + return + } + + settled = true + clearTimeout(timer) + this.off("extensionWebviewMessage", handleStateMessage) + } + + const handleStateMessage = (message: ExtensionMessage) => { + if (message.type !== "state" || !message.state) { + return + } + + if (message.state.openAiCodexIsAuthenticated) { + cleanup(timer) + resolve({ success: true }) + } + } + + const timer = setTimeout(() => { + cleanup(timer) + resolve({ + success: false, + reason: "OpenAI Codex sign-in timed out. Complete OAuth in your browser and retry. If this persists, verify localhost port 1455 is available.", + }) + }, timeoutMs) + + this.on("extensionWebviewMessage", handleStateMessage) + + try { + this.sendToExtension({ type: "openAiCodexSignIn" }) + } catch (error) { + cleanup(timer) + resolve({ + success: false, + reason: `Failed to start OpenAI Codex sign-in: ${error instanceof Error ? error.message : String(error)}`, + }) + } + }) + } + // ========================================================================== // Public Agent State API // ========================================================================== From 6e8d871f525ec6bbdf454344cd9d1644d605deed Mon Sep 17 00:00:00 2001 From: mojo-opencode Date: Sun, 22 Feb 2026 15:20:20 +0000 Subject: [PATCH 04/10] feat(cli): add onboarding oauth path for openai-codex --- apps/cli/src/commands/cli/run.ts | 10 +- .../lib/utils/__tests__/onboarding.test.ts | 106 +++++++++++++++ apps/cli/src/lib/utils/onboarding.ts | 34 ++++- apps/cli/src/types/types.ts | 7 + .../onboarding/OnboardingScreen.tsx | 123 ++++++++++++++++-- 5 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 apps/cli/src/lib/utils/__tests__/onboarding.test.ts diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index eed9e5189c0..0b1beef42ef 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -178,9 +178,17 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption let { onboardingProviderChoice } = settings if (!onboardingProviderChoice) { - const { choice, token } = await runOnboarding() + const { choice, token, provider, apiKey } = await runOnboarding() onboardingProviderChoice = choice rooToken = token ?? null + + if (provider) { + extensionHostOptions.provider = provider + } + + if (apiKey) { + extensionHostOptions.apiKey = apiKey + } } if (onboardingProviderChoice === OnboardingProviderChoice.Roo) { diff --git a/apps/cli/src/lib/utils/__tests__/onboarding.test.ts b/apps/cli/src/lib/utils/__tests__/onboarding.test.ts new file mode 100644 index 00000000000..e1dbe409ded --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/onboarding.test.ts @@ -0,0 +1,106 @@ +import { render } from "ink" + +import { login } from "@/commands/index.js" +import { saveProviderApiKey, saveSettings } from "@/lib/storage/index.js" +import { OnboardingProviderChoice } from "@/types/index.js" +import type { OnboardingSelection } from "@/ui/components/onboarding/index.js" + +import { runOnboarding } from "../onboarding.js" + +let nextSelection: OnboardingSelection + +vi.mock("ink", () => ({ + render: vi.fn((node: unknown) => { + const app = { unmount: vi.fn() } + + queueMicrotask(() => { + const onSelect = (node as { props?: { onSelect?: (selection: OnboardingSelection) => void } }).props + ?.onSelect + if (onSelect) { + void onSelect(nextSelection) + } + }) + + return app + }), +})) + +vi.mock("@/commands/index.js", () => ({ + login: vi.fn(), +})) + +vi.mock("@/lib/storage/index.js", () => ({ + saveProviderApiKey: vi.fn(), + saveSettings: vi.fn(), +})) + +vi.mock("@/ui/components/onboarding/index.js", () => ({ + OnboardingScreen: () => null, +})) + +describe("runOnboarding", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(saveSettings).mockResolvedValue(undefined) + vi.mocked(saveProviderApiKey).mockResolvedValue(undefined) + vi.spyOn(console, "log").mockImplementation(() => {}) + }) + + it("returns roo-token onboarding result for Roo choice", async () => { + nextSelection = { choice: OnboardingProviderChoice.Roo, authMethod: "roo-token" } + vi.mocked(login).mockResolvedValue({ success: true, token: "roo-token" }) + + const result = await runOnboarding() + + expect(result).toEqual({ + choice: OnboardingProviderChoice.Roo, + authMethod: "roo-token", + token: "roo-token", + skipped: false, + }) + expect(saveSettings).toHaveBeenCalledWith({ onboardingProviderChoice: OnboardingProviderChoice.Roo }) + expect(saveProviderApiKey).not.toHaveBeenCalled() + expect(render).toHaveBeenCalled() + }) + + it("returns api-key onboarding result for BYOK selection", async () => { + nextSelection = { + choice: OnboardingProviderChoice.Byok, + authMethod: "api-key", + provider: "openrouter", + apiKey: "test-openrouter-key", + } + + const result = await runOnboarding() + + expect(result).toEqual({ + choice: OnboardingProviderChoice.Byok, + authMethod: "api-key", + provider: "openrouter", + apiKey: "test-openrouter-key", + skipped: false, + }) + expect(saveSettings).toHaveBeenCalledWith({ onboardingProviderChoice: OnboardingProviderChoice.Byok }) + expect(saveSettings).toHaveBeenCalledWith({ provider: "openrouter" }) + expect(saveProviderApiKey).toHaveBeenCalledWith("openrouter", "test-openrouter-key") + }) + + it("returns oauth onboarding result for openai-codex selection", async () => { + nextSelection = { + choice: OnboardingProviderChoice.Byok, + authMethod: "oauth", + provider: "openai-codex", + } + + const result = await runOnboarding() + + expect(result).toEqual({ + choice: OnboardingProviderChoice.Byok, + authMethod: "oauth", + provider: "openai-codex", + skipped: false, + }) + expect(saveSettings).toHaveBeenCalledWith({ provider: "openai-codex" }) + expect(saveProviderApiKey).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/lib/utils/onboarding.ts b/apps/cli/src/lib/utils/onboarding.ts index 15da68f540c..e5e74350030 100644 --- a/apps/cli/src/lib/utils/onboarding.ts +++ b/apps/cli/src/lib/utils/onboarding.ts @@ -2,14 +2,16 @@ import { createElement } from "react" import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js" import { login } from "@/commands/index.js" -import { saveSettings } from "@/lib/storage/index.js" +import { saveProviderApiKey, saveSettings } from "@/lib/storage/index.js" +import type { OnboardingSelection } from "@/ui/components/onboarding/index.js" export async function runOnboarding(): Promise { const { render } = await import("ink") - const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js") + const { OnboardingScreen } = await import("@/ui/components/onboarding/index.js") return new Promise((resolve) => { - const onSelect = async (choice: OnboardingProviderChoice) => { + const onSelect = async (selection: OnboardingSelection) => { + const choice = selection.choice await saveSettings({ onboardingProviderChoice: choice }) app.unmount() @@ -22,14 +24,34 @@ export async function runOnboarding(): Promise { resolve({ choice: OnboardingProviderChoice.Roo, + authMethod: "roo-token", token: result.success ? result.token : undefined, skipped: false, }) + } else if (selection.authMethod === "oauth") { + await saveSettings({ provider: selection.provider }) + + console.log("Configured openai-codex to use OpenAI OAuth.") + console.log("") + resolve({ + choice: OnboardingProviderChoice.Byok, + authMethod: "oauth", + provider: selection.provider, + skipped: false, + }) } else { - console.log("Using your own API key.") - console.log("Set your API key via --api-key or environment variable.") + await saveSettings({ provider: selection.provider }) + await saveProviderApiKey(selection.provider, selection.apiKey) + + console.log(`Configured ${selection.provider} with a saved API key.`) console.log("") - resolve({ choice: OnboardingProviderChoice.Byok, skipped: false }) + resolve({ + choice: OnboardingProviderChoice.Byok, + authMethod: "api-key", + provider: selection.provider, + apiKey: selection.apiKey, + skipped: false, + }) } } diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 8ee5a9c6fd9..9a07278ff71 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -13,6 +13,8 @@ export const supportedProviders = [ export type SupportedProvider = (typeof supportedProviders)[number] +export type ByokProvider = Exclude + export function isSupportedProvider(provider: string): provider is SupportedProvider { return supportedProviders.includes(provider as SupportedProvider) } @@ -43,9 +45,14 @@ export enum OnboardingProviderChoice { Byok = "byok", } +export type OnboardingAuthMethod = "roo-token" | "api-key" | "oauth" + export interface OnboardingResult { choice: OnboardingProviderChoice + authMethod?: OnboardingAuthMethod token?: string + provider?: SupportedProvider + apiKey?: string skipped: boolean } diff --git a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx index 86c15f5b274..fcf31e8979f 100644 --- a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx +++ b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx @@ -1,28 +1,125 @@ -import { Box, Text } from "ink" +import { Box, Text, useInput } from "ink" import { Select } from "@inkjs/ui" +import { useMemo, useState } from "react" -import { OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js" +import { ByokProvider, OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js" + +type ApiKeyByokProvider = Exclude + +const byokProviders: Array<{ label: string; value: ApiKeyByokProvider }> = [ + { label: "OpenRouter", value: "openrouter" }, + { label: "Anthropic", value: "anthropic" }, + { label: "OpenAI", value: "openai-native" }, + { label: "Google Gemini", value: "gemini" }, + { label: "Vercel AI Gateway", value: "vercel-ai-gateway" }, +] + +export type OnboardingSelection = + | { choice: OnboardingProviderChoice.Roo; authMethod: "roo-token" } + | { choice: OnboardingProviderChoice.Byok; authMethod: "api-key"; provider: ApiKeyByokProvider; apiKey: string } + | { choice: OnboardingProviderChoice.Byok; authMethod: "oauth"; provider: "openai-codex" } export interface OnboardingScreenProps { - onSelect: (choice: OnboardingProviderChoice) => void + onSelect: (selection: OnboardingSelection) => void } export function OnboardingScreen({ onSelect }: OnboardingScreenProps) { + const [stage, setStage] = useState<"entry" | "provider" | "apiKey">("entry") + const [provider, setProvider] = useState("openrouter") + const [apiKey, setApiKey] = useState("") + + useInput((input, key) => { + if (stage !== "apiKey") { + return + } + + if (key.return) { + const trimmedKey = apiKey.trim() + if (trimmedKey.length > 0) { + onSelect({ + choice: OnboardingProviderChoice.Byok, + authMethod: "api-key", + provider, + apiKey: trimmedKey, + }) + } + return + } + + if (key.backspace || key.delete) { + setApiKey((current) => current.slice(0, -1)) + return + } + + if (key.escape) { + setStage("provider") + return + } + + if (input) { + setApiKey((current) => current + input) + } + }) + + const providerLabel = useMemo(() => { + return byokProviders.find((item) => item.value === provider)?.label ?? provider + }, [provider]) + return ( {ASCII_ROO} - Welcome! How would you like to connect to an LLM provider? - { + if (value === OnboardingProviderChoice.Roo) { + onSelect({ choice: OnboardingProviderChoice.Roo, authMethod: "roo-token" }) + return + } + + if (value === "openai-codex-oauth") { + onSelect({ + choice: OnboardingProviderChoice.Byok, + authMethod: "oauth", + provider: "openai-codex", + }) + return + } + + setStage("provider") + }} + /> + + )} + + {stage === "provider" && ( + <> + Select your provider: +