Skip to content
42 changes: 28 additions & 14 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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:
Expand Down Expand Up @@ -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 <key>` | API key for the LLM provider | From env var |
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `--provider <provider>` | API provider (roo, anthropic, openai-native, openai-codex, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
Expand All @@ -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 <roo-or-openai-codex>]` | Authenticate with Roo Code Cloud or OpenAI Codex OAuth |
| `roo auth logout [--provider <roo-or-openai-codex>]` | Sign out from Roo Code Cloud token or OpenAI Codex OAuth session |
| `roo auth status [--provider <roo-or-openai-codex>]` | 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:**

Expand Down
33 changes: 31 additions & 2 deletions apps/cli/src/agent/__tests__/extension-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ function createMessage(overrides: Partial<ClineMessage>): 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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
105 changes: 105 additions & 0 deletions apps/cli/src/agent/__tests__/extension-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/agent/extension-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -268,6 +275,14 @@ export class ExtensionClient {
return this.store.getCurrentMode()
}

getProviderAuthState(): {
openAiCodexIsAuthenticated?: boolean
} {
return {
openAiCodexIsAuthenticated: this.providerAuthState.openAiCodexIsAuthenticated,
}
}

// ===========================================================================
// Event Subscriptions - Realtime notifications
// ===========================================================================
Expand Down Expand Up @@ -495,6 +510,7 @@ export class ExtensionClient {
*/
reset(): void {
this.store.reset()
this.providerAuthState = {}
this.emitter.removeAllListeners()
}

Expand Down
Loading
Loading