diff --git a/apps/cli/README.md b/apps/cli/README.md index 62b03e5cd88..0ca8621aee4 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -109,6 +109,12 @@ Use `--print` for non-interactive execution and machine-readable output: roo --print "Summarize this repository" ``` +If you use `--provider openai-codex` in non-interactive mode, you must pre-authenticate first: + +```bash +roo auth login --provider openai-codex +``` + ### Stdin Stream Mode (`--stdin-prompt-stream`) For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. @@ -131,6 +137,11 @@ roo auth status # Log out roo auth logout + +# OpenAI Codex OAuth (ChatGPT Plus/Pro) +roo auth login --provider openai-codex +roo auth status --provider openai-codex +roo auth logout --provider openai-codex ``` The `auth login` command: @@ -175,7 +186,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | | `-a, --require-approval` | Require manual approval before actions execute | `false` | | `-k, --api-key ` | API key for the LLM provider | From env var | -| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `--provider ` | API provider (roo, anthropic, openai-native, openai-codex, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | | `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | | `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | | `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | @@ -185,24 +196,27 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Auth Commands -| Command | Description | -| ----------------- | ---------------------------------- | -| `roo auth login` | Authenticate with Roo Code Cloud | -| `roo auth logout` | Clear stored authentication token | -| `roo auth status` | Show current authentication status | +| Command | Description | +| ---------------------------------------------------- | ---------------------------------------------------------------- | +| `roo auth login [--provider ]` | Authenticate with Roo Code Cloud or OpenAI Codex OAuth | +| `roo auth logout [--provider ]` | Sign out from Roo Code Cloud token or OpenAI Codex OAuth session | +| `roo auth status [--provider ]` | Show authentication status for Roo Code Cloud or OpenAI Codex | ## Environment Variables The CLI will look for API keys in environment variables if not provided via `--api-key`: -| Provider | Environment Variable | -| ----------------- | --------------------------- | -| roo | `ROO_API_KEY` | -| anthropic | `ANTHROPIC_API_KEY` | -| openai-native | `OPENAI_API_KEY` | -| openrouter | `OPENROUTER_API_KEY` | -| gemini | `GOOGLE_API_KEY` | -| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | +| Provider | Environment Variable | +| ----------------- | ----------------------------------- | +| roo | `ROO_API_KEY` | +| anthropic | `ANTHROPIC_API_KEY` | +| openai-native | `OPENAI_API_KEY` | +| openai-codex | OAuth session (no API key required) | +| openrouter | `OPENROUTER_API_KEY` | +| gemini | `GOOGLE_API_KEY` | +| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | + +`openai-codex` uses OAuth session auth and does not read `OPENAI_API_KEY`; that variable is for `openai-native`. **Authentication Environment Variables:** 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..1128fccd639 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -215,6 +215,22 @@ describe("ExtensionHost", () => { ) expect(updateSettingsCall).toBeDefined() }) + + it("should skip updateSettings message when skipInitialSettingsSync is enabled", () => { + const host = createTestHost({ skipInitialSettingsSync: true }) + const emitSpy = vi.spyOn(host, "emit") + + host.markWebviewReady() + + const updateSettingsCall = emitSpy.mock.calls.find( + (call) => + call[0] === "webviewMessage" && + typeof call[1] === "object" && + call[1] !== null && + (call[1] as WebviewMessage).type === "updateSettings", + ) + expect(updateSettingsCall).toBeUndefined() + }) }) }) @@ -285,6 +301,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 +554,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..ac4242185ac 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -93,6 +93,11 @@ export interface ExtensionHostOptions { * running in an integration test and we want to see the output. */ integrationTest?: boolean + /** + * When true, skip sending initial settings into extension runtime state. + * Useful for auth/status utility flows that should not mutate persisted settings. + */ + skipInitialSettingsSync?: boolean } interface ExtensionModule { @@ -107,6 +112,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 @@ -426,12 +434,14 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac public markWebviewReady(): void { this.isReady = true - // Apply CLI settings to the runtime config and context proxy BEFORE - // sending webviewDidLaunch. This prevents a race condition where the - // webviewDidLaunch handler's first-time init sync reads default state - // (apiProvider: "anthropic") instead of the CLI-provided settings. - setRuntimeConfigValues("roo-cline", this.initialSettings as Record) - this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) + if (!this.options.skipInitialSettingsSync) { + // Apply CLI settings to the runtime config and context proxy BEFORE + // sending webviewDidLaunch. This prevents a race condition where the + // webviewDidLaunch handler's first-time init sync reads default state + // (apiProvider: "anthropic") instead of the CLI-provided settings. + setRuntimeConfigValues("roo-cline", this.initialSettings as Record) + this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) + } // Now trigger extension initialization. The context proxy should already // have CLI-provided values when the webviewDidLaunch handler runs. @@ -459,6 +469,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 +519,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 // ========================================================================== diff --git a/apps/cli/src/commands/auth/__tests__/login.test.ts b/apps/cli/src/commands/auth/__tests__/login.test.ts new file mode 100644 index 00000000000..b95d3a130f7 --- /dev/null +++ b/apps/cli/src/commands/auth/__tests__/login.test.ts @@ -0,0 +1,53 @@ +import { login } from "../login.js" + +import { loginWithOpenAiCodex } from "../openai-codex-auth.js" + +vi.mock("../openai-codex-auth.js", () => ({ + loginWithOpenAiCodex: vi.fn(), +})) + +describe("auth login provider routing", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + it("uses openai-codex auth flow when provider is openai-codex", async () => { + vi.mocked(loginWithOpenAiCodex).mockResolvedValue({ success: true }) + + const result = await login({ + provider: "openai-codex", + workspace: "/tmp/workspace", + extension: "/tmp/extension", + verbose: true, + timeout: 42, + }) + + expect(result).toEqual({ success: true }) + expect(loginWithOpenAiCodex).toHaveBeenCalledWith({ + workspace: "/tmp/workspace", + extension: "/tmp/extension", + debug: true, + timeoutMs: 42, + }) + }) + + it("returns failed result when openai-codex auth flow fails", async () => { + vi.mocked(loginWithOpenAiCodex).mockResolvedValue({ success: false, reason: "Timed out" }) + + const result = await login({ provider: "openai-codex" }) + + expect(result).toEqual({ success: false, error: "Timed out" }) + }) + + it("rejects unsupported auth providers", async () => { + const result = await login({ provider: "anthropic" }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("Unsupported auth provider") + } + expect(loginWithOpenAiCodex).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/commands/auth/__tests__/logout.test.ts b/apps/cli/src/commands/auth/__tests__/logout.test.ts new file mode 100644 index 00000000000..5fb565fa67a --- /dev/null +++ b/apps/cli/src/commands/auth/__tests__/logout.test.ts @@ -0,0 +1,67 @@ +import { logout } from "../logout.js" + +import { clearToken, getCredentialsPath, hasToken } from "@/lib/storage/index.js" + +import { logoutOpenAiCodex } from "../openai-codex-auth.js" + +vi.mock("@/lib/storage/index.js", () => ({ + clearToken: vi.fn(), + getCredentialsPath: vi.fn(() => "/tmp/credentials.json"), + hasToken: vi.fn(), +})) + +vi.mock("../openai-codex-auth.js", () => ({ + logoutOpenAiCodex: vi.fn(), +})) + +describe("auth logout provider routing", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + it("keeps roo logout behavior by default", async () => { + vi.mocked(hasToken).mockResolvedValue(true) + + const result = await logout({ verbose: true }) + + expect(result).toEqual({ success: true, wasLoggedIn: true }) + expect(getCredentialsPath).toHaveBeenCalled() + expect(clearToken).toHaveBeenCalled() + expect(logoutOpenAiCodex).not.toHaveBeenCalled() + }) + + it("uses openai-codex sign-out flow when provider is openai-codex", async () => { + vi.mocked(logoutOpenAiCodex).mockResolvedValue({ success: true, wasAuthenticated: true }) + + const result = await logout({ provider: "openai-codex", timeoutMs: 5_000 }) + + expect(result).toEqual({ success: true, wasLoggedIn: true }) + expect(logoutOpenAiCodex).toHaveBeenCalledWith({ + workspace: undefined, + extension: undefined, + debug: false, + timeoutMs: 5000, + }) + }) + + it("returns failed result when openai-codex sign-out fails", async () => { + vi.mocked(logoutOpenAiCodex).mockResolvedValue({ + success: false, + wasAuthenticated: true, + reason: "Timed out", + }) + + const result = await logout({ provider: "openai-codex" }) + + expect(result).toEqual({ success: false, wasLoggedIn: true }) + }) + + it("rejects unsupported auth providers", async () => { + const result = await logout({ provider: "anthropic" }) + + expect(result).toEqual({ success: false, wasLoggedIn: false }) + expect(logoutOpenAiCodex).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/commands/auth/__tests__/status.test.ts b/apps/cli/src/commands/auth/__tests__/status.test.ts new file mode 100644 index 00000000000..b59c1eb6dd6 --- /dev/null +++ b/apps/cli/src/commands/auth/__tests__/status.test.ts @@ -0,0 +1,67 @@ +import { status } from "../status.js" + +import { loadCredentials, loadToken } from "@/lib/storage/index.js" + +import { statusOpenAiCodex } from "../openai-codex-auth.js" + +vi.mock("@/lib/storage/index.js", () => ({ + loadToken: vi.fn(), + loadCredentials: vi.fn(), + getCredentialsPath: vi.fn(() => "/tmp/credentials.json"), +})) + +vi.mock("@/lib/auth/index.js", () => ({ + isTokenExpired: vi.fn(() => false), + isTokenValid: vi.fn(() => true), + getTokenExpirationDate: vi.fn(() => null), +})) + +vi.mock("../openai-codex-auth.js", () => ({ + statusOpenAiCodex: vi.fn(), +})) + +describe("auth status provider routing", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + it("keeps roo status behavior by default", async () => { + vi.mocked(loadToken).mockResolvedValue("roo-token") + vi.mocked(loadCredentials).mockResolvedValue(null) + + const result = await status() + + expect(result.authenticated).toBe(true) + expect(statusOpenAiCodex).not.toHaveBeenCalled() + }) + + it("uses openai-codex status flow when provider is openai-codex", async () => { + vi.mocked(statusOpenAiCodex).mockResolvedValue({ authenticated: true }) + + const result = await status({ provider: "openai-codex" }) + + expect(result).toEqual({ authenticated: true }) + expect(statusOpenAiCodex).toHaveBeenCalledWith({ + workspace: undefined, + extension: undefined, + debug: false, + }) + }) + + it("returns not authenticated when openai-codex status is unauthenticated", async () => { + vi.mocked(statusOpenAiCodex).mockResolvedValue({ authenticated: false }) + + const result = await status({ provider: "openai-codex" }) + + expect(result).toEqual({ authenticated: false }) + }) + + it("rejects unsupported auth providers", async () => { + const result = await status({ provider: "anthropic" }) + + expect(result).toEqual({ authenticated: false }) + expect(statusOpenAiCodex).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index ab85385b0f9..527d9d54f06 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -3,18 +3,38 @@ import { randomBytes } from "crypto" import net from "net" import { exec } from "child_process" +import type { SupportedProvider } from "@/types/index.js" import { AUTH_BASE_URL } from "@/types/index.js" import { saveToken } from "@/lib/storage/index.js" +import { loginWithOpenAiCodex } from "./openai-codex-auth.js" + +type AuthProvider = "roo" | "openai-codex" + +function resolveAuthProvider(provider: SupportedProvider | undefined): AuthProvider | null { + if (!provider || provider === "roo") { + return "roo" + } + + if (provider === "openai-codex") { + return "openai-codex" + } + + return null +} + export interface LoginOptions { timeout?: number verbose?: boolean + provider?: SupportedProvider + workspace?: string + extension?: string } export type LoginResult = | { success: true - token: string + token?: string } | { success: false @@ -23,7 +43,34 @@ export type LoginResult = const LOCALHOST = "127.0.0.1" -export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise { +export async function login(options: LoginOptions = {}): Promise { + const { timeout = 5 * 60 * 1000, verbose = false, provider, workspace, extension } = options + const authProvider = resolveAuthProvider(provider) + + if (!authProvider) { + const error = `Unsupported auth provider: ${provider}. Use roo or openai-codex.` + console.error(`[CLI] ${error}`) + return { success: false, error } + } + + if (authProvider === "openai-codex") { + const result = await loginWithOpenAiCodex({ + workspace, + extension, + debug: verbose, + timeoutMs: timeout, + }) + + if (result.success) { + console.log("✓ Successfully authenticated with OpenAI Codex") + return { success: true } + } + + const error = result.reason ?? "Unknown OpenAI Codex authentication error" + console.error(`✗ OpenAI Codex authentication failed: ${error}`) + return { success: false, error } + } + const state = randomBytes(16).toString("hex") const port = await getAvailablePort() const host = `http://${LOCALHOST}:${port}` diff --git a/apps/cli/src/commands/auth/logout.ts b/apps/cli/src/commands/auth/logout.ts index 61c3cb37a49..b57d49bd036 100644 --- a/apps/cli/src/commands/auth/logout.ts +++ b/apps/cli/src/commands/auth/logout.ts @@ -1,7 +1,28 @@ +import type { SupportedProvider } from "@/types/index.js" import { clearToken, hasToken, getCredentialsPath } from "@/lib/storage/index.js" +import { logoutOpenAiCodex } from "./openai-codex-auth.js" + +type AuthProvider = "roo" | "openai-codex" + +function resolveAuthProvider(provider: SupportedProvider | undefined): AuthProvider | null { + if (!provider || provider === "roo") { + return "roo" + } + + if (provider === "openai-codex") { + return "openai-codex" + } + + return null +} + export interface LogoutOptions { verbose?: boolean + provider?: SupportedProvider + workspace?: string + extension?: string + timeoutMs?: number } export interface LogoutResult { @@ -9,7 +30,37 @@ export interface LogoutResult { wasLoggedIn: boolean } -export async function logout({ verbose = false }: LogoutOptions = {}): Promise { +export async function logout(options: LogoutOptions = {}): Promise { + const { verbose = false, provider, workspace, extension, timeoutMs } = options + const authProvider = resolveAuthProvider(provider) + + if (!authProvider) { + console.error(`[CLI] Unsupported auth provider: ${provider}. Use roo or openai-codex.`) + return { success: false, wasLoggedIn: false } + } + + if (authProvider === "openai-codex") { + const result = await logoutOpenAiCodex({ + workspace, + extension, + debug: verbose, + timeoutMs, + }) + + if (!result.success) { + console.error(`✗ Failed to sign out from OpenAI Codex: ${result.reason ?? "Unknown error"}`) + return { success: false, wasLoggedIn: result.wasAuthenticated } + } + + if (!result.wasAuthenticated) { + console.log("You are not currently signed in to OpenAI Codex.") + return { success: true, wasLoggedIn: false } + } + + console.log("✓ Successfully signed out from OpenAI Codex") + return { success: true, wasLoggedIn: true } + } + const wasLoggedIn = await hasToken() if (!wasLoggedIn) { diff --git a/apps/cli/src/commands/auth/openai-codex-auth.ts b/apps/cli/src/commands/auth/openai-codex-auth.ts new file mode 100644 index 00000000000..9f49e9eb9d0 --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-auth.ts @@ -0,0 +1,203 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import pWaitFor from "p-wait-for" + +import { getProviderDefaultModelId, type ExtensionMessage } from "@roo-code/types" + +import { type ExtensionHostOptions, ExtensionHost } from "@/agent/index.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const OPENAI_CODEX_AUTH_TIMEOUT_MS = 300_000 + +export interface OpenAiCodexAuthOptions { + workspace?: string + extension?: string + debug?: boolean + timeoutMs?: number +} + +export interface OpenAiCodexSignInResult { + success: boolean + reason?: string +} + +export interface OpenAiCodexSignOutResult { + success: boolean + wasAuthenticated: boolean + reason?: string +} + +function resolveWorkspacePath(workspace: string | undefined): string { + const resolved = workspace ? path.resolve(workspace) : process.cwd() + + if (!fs.existsSync(resolved)) { + throw new Error(`Workspace path does not exist: ${resolved}`) + } + + return resolved +} + +function resolveExtensionPath(extension: string | undefined): string { + const resolved = path.resolve(extension || getDefaultExtensionPath(__dirname)) + + if (!fs.existsSync(path.join(resolved, "extension.js"))) { + throw new Error(`Extension bundle not found at: ${resolved}`) + } + + return resolved +} + +async function withOpenAiCodexHost( + options: OpenAiCodexAuthOptions, + fn: (host: ExtensionHost) => Promise, +): Promise { + const extensionHostOptions: ExtensionHostOptions = { + mode: "code", + reasoningEffort: undefined, + user: null, + provider: "openai-codex", + model: getProviderDefaultModelId("openai-codex"), + workspacePath: resolveWorkspacePath(options.workspace), + extensionPath: resolveExtensionPath(options.extension), + nonInteractive: true, + ephemeral: false, + debug: options.debug ?? false, + exitOnComplete: true, + exitOnError: false, + disableOutput: true, + skipInitialSettingsSync: true, + } + + const host = new ExtensionHost(extensionHostOptions) + + await host.activate() + await pWaitFor(() => host.client.isInitialized(), { + interval: 25, + timeout: 2_000, + }).catch(() => undefined) + + try { + return await fn(host) + } finally { + await host.dispose() + } +} + +function isOpenAiCodexAuthenticated(host: ExtensionHost): boolean { + return host.client.getProviderAuthState().openAiCodexIsAuthenticated === true +} + +async function waitForOpenAiCodexAuthState( + host: ExtensionHost, + expectedState: boolean, + timeoutMs: number, +): Promise { + if (isOpenAiCodexAuthenticated(host) === expectedState) { + return true + } + + return new Promise((resolve) => { + let settled = false + + const cleanup = () => { + if (settled) { + return + } + + settled = true + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) + } + + const finish = (result: boolean) => { + cleanup() + resolve(result) + } + + const onMessage = (message: ExtensionMessage) => { + if (message.type !== "state" || !message.state || message.state.openAiCodexIsAuthenticated === undefined) { + return + } + + if (message.state.openAiCodexIsAuthenticated === expectedState) { + finish(true) + } + } + + const timeoutId = setTimeout(() => finish(false), timeoutMs) + + host.on("extensionWebviewMessage", onMessage) + }) +} + +export async function loginWithOpenAiCodex(options: OpenAiCodexAuthOptions = {}): Promise { + try { + return await withOpenAiCodexHost(options, async (host) => { + const authResult = await host.ensureOpenAiCodexAuthenticated({ + timeoutMs: options.timeoutMs ?? OPENAI_CODEX_AUTH_TIMEOUT_MS, + }) + + if (authResult.success) { + return { success: true } + } + + return { success: false, reason: authResult.reason } + }) + } catch (error) { + return { + success: false, + reason: error instanceof Error ? error.message : String(error), + } + } +} + +export async function logoutOpenAiCodex(options: OpenAiCodexAuthOptions = {}): Promise { + try { + return await withOpenAiCodexHost(options, async (host) => { + const wasAuthenticated = isOpenAiCodexAuthenticated(host) + + if (!wasAuthenticated) { + return { success: true, wasAuthenticated: false } + } + + host.sendToExtension({ type: "openAiCodexSignOut" }) + + const signedOut = await waitForOpenAiCodexAuthState(host, false, options.timeoutMs ?? 10_000) + + if (!signedOut) { + return { + success: false, + wasAuthenticated: true, + reason: "Timed out waiting for OpenAI Codex sign-out to complete.", + } + } + + return { success: true, wasAuthenticated: true } + }) + } catch (error) { + return { + success: false, + wasAuthenticated: false, + reason: error instanceof Error ? error.message : String(error), + } + } +} + +export async function statusOpenAiCodex( + options: OpenAiCodexAuthOptions = {}, +): Promise<{ authenticated: boolean; reason?: string }> { + try { + return await withOpenAiCodexHost(options, async (host) => ({ + authenticated: isOpenAiCodexAuthenticated(host), + })) + } catch (error) { + return { + authenticated: false, + reason: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/apps/cli/src/commands/auth/status.ts b/apps/cli/src/commands/auth/status.ts index 9e81adfda8a..d2b79cd9b8e 100644 --- a/apps/cli/src/commands/auth/status.ts +++ b/apps/cli/src/commands/auth/status.ts @@ -1,8 +1,28 @@ +import type { SupportedProvider } from "@/types/index.js" import { loadToken, loadCredentials, getCredentialsPath } from "@/lib/storage/index.js" import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "@/lib/auth/index.js" +import { statusOpenAiCodex } from "./openai-codex-auth.js" + +type AuthProvider = "roo" | "openai-codex" + +function resolveAuthProvider(provider: SupportedProvider | undefined): AuthProvider | null { + if (!provider || provider === "roo") { + return "roo" + } + + if (provider === "openai-codex") { + return "openai-codex" + } + + return null +} + export interface StatusOptions { verbose?: boolean + provider?: SupportedProvider + workspace?: string + extension?: string } export interface StatusResult { @@ -16,7 +36,34 @@ export interface StatusResult { } export async function status(options: StatusOptions = {}): Promise { - const { verbose = false } = options + const { verbose = false, provider, workspace, extension } = options + const authProvider = resolveAuthProvider(provider) + + if (!authProvider) { + console.error(`[CLI] Unsupported auth provider: ${provider}. Use roo or openai-codex.`) + return { authenticated: false } + } + + if (authProvider === "openai-codex") { + const codexStatus = await statusOpenAiCodex({ + workspace, + extension, + debug: verbose, + }) + + if (codexStatus.authenticated) { + console.log("✓ Authenticated with OpenAI Codex") + return { authenticated: true } + } + + console.log("✗ Not authenticated with OpenAI Codex") + console.log("") + console.log("Run: roo auth login --provider openai-codex") + if (codexStatus.reason && verbose) { + console.log(`Reason: ${codexStatus.reason}`) + } + return { authenticated: false } + } const token = await loadToken() diff --git a/apps/cli/src/commands/cli/__tests__/run.test.ts b/apps/cli/src/commands/cli/__tests__/run.test.ts index 7b7693a39cd..50204c16170 100644 --- a/apps/cli/src/commands/cli/__tests__/run.test.ts +++ b/apps/cli/src/commands/cli/__tests__/run.test.ts @@ -2,6 +2,165 @@ 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, assertNonInteractiveOAuthReady, 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") + }) + + it("fails fast for unauthenticated openai-codex in non-interactive mode", () => { + expect(() => + assertNonInteractiveOAuthReady({ + provider: "openai-codex", + interactive: false, + providerAuthState: { openAiCodexIsAuthenticated: false }, + }), + ).toThrow( + "openai-codex requires interactive OAuth. Run in TTY or pre-auth with roo auth login --provider openai-codex.", + ) + }) + + it("allows non-interactive openai-codex when already authenticated", () => { + expect(() => + assertNonInteractiveOAuthReady({ + provider: "openai-codex", + interactive: false, + providerAuthState: { openAiCodexIsAuthenticated: true }, + }), + ).not.toThrow() + }) + + it("does not apply oauth fail-fast check to non-oauth providers", () => { + expect(() => + assertNonInteractiveOAuthReady({ + provider: "openrouter", + interactive: false, + providerAuthState: {}, + }), + ).not.toThrow() + }) +}) + 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..b9099b756e9 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -3,13 +3,16 @@ import path from "path" import { fileURLToPath } from "url" import { createElement } from "react" +import pWaitFor from "p-wait-for" import { setLogger } from "@roo-code/vscode-shim" import { + CliSettings, FlagOptions, isSupportedProvider, OnboardingProviderChoice, + SupportedProvider, supportedProviders, DEFAULT_FLAGS, REASONING_EFFORTS, @@ -20,8 +23,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 +34,109 @@ import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) +type ProviderAuthentication = { + apiKey?: string + rooUser?: NonNullable + needsOAuthBootstrap?: boolean + invalidRooToken?: boolean +} + +const OPENAI_CODEX_NON_INTERACTIVE_AUTH_MESSAGE = + "openai-codex requires interactive OAuth. Run in TTY or pre-auth with roo auth login --provider openai-codex." + +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.`) + const envVarName = getEnvVarName(provider) + if (envVarName) { + console.error(`[CLI] For ${provider}, set ${envVarName}`) + } + } + + process.exit(1) +} + +export function assertNonInteractiveOAuthReady(params: { + provider: SupportedProvider + interactive: boolean + providerAuthState: { openAiCodexIsAuthenticated?: boolean } +}): void { + if ( + params.provider === "openai-codex" && + !params.interactive && + params.providerAuthState.openAiCodexIsAuthenticated !== true + ) { + throw new Error(OPENAI_CODEX_NON_INTERACTIVE_AUTH_MESSAGE) + } +} + export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -93,9 +199,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) { @@ -103,33 +217,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 +228,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) @@ -296,6 +384,19 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption try { await host.activate() + if (!isTuiEnabled && extensionHostOptions.provider === "openai-codex") { + await pWaitFor(() => host.client.getProviderAuthState().openAiCodexIsAuthenticated !== undefined, { + interval: 25, + timeout: 2_000, + }).catch(() => undefined) + } + + assertNonInteractiveOAuthReady({ + provider: extensionHostOptions.provider, + interactive: isTuiEnabled, + providerAuthState: host.client.getProviderAuthState(), + }) + if (jsonEmitter) { jsonEmitter.attachToClient(host.client) } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8b817db77f4..e030bd61649 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import { DEFAULT_FLAGS } from "@/types/constants.js" +import type { SupportedProvider } from "@/types/index.js" import { VERSION } from "@/lib/utils/version.js" import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js" @@ -25,7 +26,7 @@ program .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) .option("-a, --require-approval", "Require manual approval for actions", false) .option("-k, --api-key ", "API key for the LLM provider") - .option("--provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") + .option("--provider ", "API provider (roo, anthropic, openai-native, openai-codex, openrouter, etc.)") .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) .option("--mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) .option( @@ -86,29 +87,65 @@ const authCommand = program.command("auth").description("Manage authentication f authCommand .command("login") - .description("Authenticate with Roo Code Cloud") + .description("Authenticate with Roo Code Cloud or OpenAI Codex") .option("-v, --verbose", "Enable verbose output", false) - .action(async (options: { verbose: boolean }) => { - const result = await login({ verbose: options.verbose }) - process.exit(result.success ? 0 : 1) - }) + .option("--provider ", "Auth provider (roo or openai-codex)") + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .action( + async (options: { verbose: boolean; provider?: string; workspace?: string; extension?: string }, command) => { + const provider = (options.provider ?? command.optsWithGlobals().provider ?? "roo") as SupportedProvider + + const result = await login({ + verbose: options.verbose, + provider, + workspace: options.workspace, + extension: options.extension, + }) + process.exit(result.success ? 0 : 1) + }, + ) authCommand .command("logout") - .description("Log out from Roo Code Cloud") + .description("Log out from Roo Code Cloud or OpenAI Codex") .option("-v, --verbose", "Enable verbose output", false) - .action(async (options: { verbose: boolean }) => { - const result = await logout({ verbose: options.verbose }) - process.exit(result.success ? 0 : 1) - }) + .option("--provider ", "Auth provider (roo or openai-codex)") + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .action( + async (options: { verbose: boolean; provider?: string; workspace?: string; extension?: string }, command) => { + const provider = (options.provider ?? command.optsWithGlobals().provider ?? "roo") as SupportedProvider + + const result = await logout({ + verbose: options.verbose, + provider, + workspace: options.workspace, + extension: options.extension, + }) + process.exit(result.success ? 0 : 1) + }, + ) authCommand .command("status") .description("Show authentication status") .option("-v, --verbose", "Enable verbose output", false) - .action(async (options: { verbose: boolean }) => { - const result = await status({ verbose: options.verbose }) - process.exit(result.authenticated ? 0 : 1) - }) + .option("--provider ", "Auth provider (roo or openai-codex)") + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .action( + async (options: { verbose: boolean; provider?: string; workspace?: string; extension?: string }, command) => { + const provider = (options.provider ?? command.optsWithGlobals().provider ?? "roo") as SupportedProvider + + const result = await status({ + verbose: options.verbose, + provider, + workspace: options.workspace, + extension: options.extension, + }) + process.exit(result.authenticated ? 0 : 1) + }, + ) program.parse() diff --git a/apps/cli/src/lib/storage/__tests__/credentials.test.ts b/apps/cli/src/lib/storage/__tests__/credentials.test.ts index 574b1b6bf40..e6ff35febed 100644 --- a/apps/cli/src/lib/storage/__tests__/credentials.test.ts +++ b/apps/cli/src/lib/storage/__tests__/credentials.test.ts @@ -18,7 +18,16 @@ vi.mock("../config-dir.js", () => ({ })) // Import after mocking -import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js" +import { + saveToken, + loadToken, + loadCredentials, + clearToken, + hasToken, + getCredentialsPath, + saveProviderApiKey, + loadProviderApiKey, +} from "../credentials.js" // Re-derive the test config dir for use in tests (must match the hoisted one) const actualTestConfigDir = getTestConfigDir() @@ -134,6 +143,16 @@ describe("Token Storage", () => { it("should not throw if no token exists", async () => { await expect(clearToken()).resolves.not.toThrow() }) + + it("should preserve provider API keys when clearing token", async () => { + await saveToken("test-token") + await saveProviderApiKey("openrouter", "or-key") + + await clearToken() + + expect(await loadToken()).toBeNull() + expect(await loadProviderApiKey("openrouter")).toBe("or-key") + }) }) describe("hasToken", () => { @@ -149,4 +168,26 @@ describe("Token Storage", () => { expect(exists).toBe(false) }) }) + + describe("provider API keys", () => { + it("should save and load provider API keys", async () => { + await saveProviderApiKey("anthropic", "anthropic-key") + + const loaded = await loadProviderApiKey("anthropic") + expect(loaded).toBe("anthropic-key") + }) + + it("should return null for missing provider API key", async () => { + const loaded = await loadProviderApiKey("gemini") + expect(loaded).toBeNull() + }) + + it("should preserve token data when saving provider API keys", async () => { + await saveToken("token-123", { userId: "user_1" }) + await saveProviderApiKey("openai-native", "openai-key") + + expect(await loadToken()).toBe("token-123") + expect(await loadProviderApiKey("openai-native")).toBe("openai-key") + }) + }) }) diff --git a/apps/cli/src/lib/storage/credentials.ts b/apps/cli/src/lib/storage/credentials.ts index b687111c16f..c1d0286ecae 100644 --- a/apps/cli/src/lib/storage/credentials.ts +++ b/apps/cli/src/lib/storage/credentials.ts @@ -1,65 +1,104 @@ import fs from "fs/promises" import path from "path" +import type { SupportedProvider } from "@/types/index.js" + import { getConfigDir } from "./index.js" const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json") export interface Credentials { - token: string + token?: string createdAt: string userId?: string orgId?: string + apiKeys?: Partial> } -export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise { +async function loadCredentialsFromDisk(): Promise { + try { + const data = await fs.readFile(CREDENTIALS_FILE, "utf-8") + return JSON.parse(data) as Credentials + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null + } + throw error + } +} + +async function saveCredentials(credentials: Credentials): Promise { await fs.mkdir(getConfigDir(), { recursive: true }) + await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { + mode: 0o600, + }) +} + +export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise { + const existing = await loadCredentialsFromDisk() const credentials: Credentials = { token, createdAt: new Date().toISOString(), userId: options?.userId, orgId: options?.orgId, + apiKeys: existing?.apiKeys, } - await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { - mode: 0o600, // Read/write for owner only - }) + await saveCredentials(credentials) } export async function loadToken(): Promise { - try { - const data = await fs.readFile(CREDENTIALS_FILE, "utf-8") - const credentials: Credentials = JSON.parse(data) - return credentials.token - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null - } - throw error - } + const credentials = await loadCredentialsFromDisk() + return credentials?.token ?? null } export async function loadCredentials(): Promise { - try { - const data = await fs.readFile(CREDENTIALS_FILE, "utf-8") - return JSON.parse(data) as Credentials - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null - } - throw error + return loadCredentialsFromDisk() +} + +export async function saveProviderApiKey(provider: SupportedProvider, apiKey: string): Promise { + const existing = await loadCredentialsFromDisk() + const credentials: Credentials = { + token: existing?.token, + createdAt: existing?.createdAt ?? new Date().toISOString(), + userId: existing?.userId, + orgId: existing?.orgId, + apiKeys: { + ...(existing?.apiKeys ?? {}), + [provider]: apiKey, + }, } + + await saveCredentials(credentials) +} + +export async function loadProviderApiKey(provider: SupportedProvider): Promise { + const credentials = await loadCredentialsFromDisk() + return credentials?.apiKeys?.[provider] ?? null } export async function clearToken(): Promise { - try { - await fs.unlink(CREDENTIALS_FILE) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error + const credentials = await loadCredentialsFromDisk() + if (!credentials) { + return + } + + const { token: _token, userId: _userId, orgId: _orgId, ...rest } = credentials + const hasApiKeys = Boolean(rest.apiKeys && Object.keys(rest.apiKeys).length > 0) + + if (!hasApiKeys) { + try { + await fs.unlink(CREDENTIALS_FILE) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } } + return } + + await saveCredentials(rest) } export async function hasToken(): Promise { 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/__tests__/provider.test.ts b/apps/cli/src/lib/utils/__tests__/provider.test.ts index 70d8a2a5557..322c1ecff88 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 not read API key from environment variable for openai-codex", () => { + process.env.OPENAI_API_KEY = "test-openai-codex-key" + expect(getApiKeyFromEnv("openai-codex")).toBeUndefined() + }) + 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/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/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1b..71b763c2a7b 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -2,7 +2,28 @@ import { RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" -const envVarMap: Record = { +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: Partial> = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", gemini: "GOOGLE_API_KEY", @@ -11,12 +32,15 @@ const envVarMap: Record = { roo: "ROO_API_KEY", } -export function getEnvVarName(provider: SupportedProvider): string { +export function getEnvVarName(provider: SupportedProvider): string | undefined { return envVarMap[provider] } export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined { const envVar = getEnvVarName(provider) + if (!envVar) { + return undefined + } return process.env[envVar] } @@ -36,6 +60,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..9a07278ff71 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", @@ -12,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) } @@ -42,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: +