Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/codex-native/oauth-auth-methods.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { CodexSpoofMode } from "../config.js"
import type { OpenAIAuthMode } from "../types.js"
import { resolveRequestUserAgent } from "./client-identity.js"
import { resolveCodexOriginator } from "./originator.js"
import {
buildAuthorizeUrl,
CLIENT_ID,
Expand All @@ -13,10 +12,11 @@ import {
OAUTH_DEVICE_AUTH_TIMEOUT_MS,
OAUTH_HTTP_TIMEOUT_MS,
OAUTH_POLLING_SAFETY_MARGIN_MS,
sleep,
type PkceCodes,
sleep,
type TokenResponse
} from "./oauth-utils.js"
import { resolveCodexOriginator } from "./originator.js"

type OAuthSuccess = {
type: "success"
Expand Down Expand Up @@ -101,6 +101,9 @@ function toOAuthSuccess(tokens: TokenResponse): OAuthSuccess {

export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) {
return async (inputs?: Record<string, string>): Promise<OAuthAuthorizePayload> => {
const shouldUseInteractiveMenu =
process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY

const runSingleBrowserOAuthInline = async (): Promise<TokenResponse | null> => {
let redirectUri: string
try {
Expand Down Expand Up @@ -184,7 +187,7 @@ export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) {
}
}

if (inputs && process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY) {
if (shouldUseInteractiveMenu) {
return runInteractiveBrowserAuthLoop()
}

Expand Down
30 changes: 30 additions & 0 deletions test/codex-native-auth-menu-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,36 @@ describe("codex-native auth menu wiring", () => {
}
})

it("shows interactive menu in tty login flow when authorize inputs are omitted", async () => {
const { hooks, runAuthMenuOnce } = await loadPluginWithMenu({
offerLegacyTransfer: true,
menuResult: "exit"
})
const browserMethod = hooks.auth?.methods.find((method) => method.label === "ChatGPT Pro/Plus (browser)")
expect(browserMethod).toBeDefined()
if (!browserMethod || browserMethod.type !== "oauth") throw new Error("Missing browser oauth method")

const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }
const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean }
const prevIn = stdin.isTTY
const prevOut = stdout.isTTY
stdin.isTTY = true
stdout.isTTY = true

try {
const flow = await browserMethod.authorize()
expect(runAuthMenuOnce).toHaveBeenCalledTimes(1)
expect(flow.instructions).toBe("Login cancelled.")
expect(flow.method).toBe("auto")
expect(flow.url).toBe("")
const result = await flow.callback("")
expect(result.type).toBe("failed")
} finally {
stdin.isTTY = prevIn
stdout.isTTY = prevOut
}
})

it("passes transfer availability flag into auth menu runner", async () => {
const { hooks, runAuthMenuOnce } = await loadPluginWithMenu({
offerLegacyTransfer: true,
Expand Down
97 changes: 97 additions & 0 deletions test/codex-native-oauth-auth-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,47 @@ describe("createBrowserOAuthAuthorize", () => {
}
})

it("defaults missing authorize inputs to the interactive auth menu in tty mode", async () => {
const scheduleOAuthServerStop = vi.fn()
const persistOAuthTokens = vi.fn()
const openAuthUrl = vi.fn()
const runInteractiveAuthMenu = vi.fn<(options: { allowExit: boolean }) => Promise<"add" | "exit">>(
async () => "exit"
)

const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }
const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean }
const previousIn = stdin.isTTY
const previousOut = stdout.isTTY
stdin.isTTY = true
stdout.isTTY = true

try {
const authorize = createBrowserOAuthAuthorize({
authMode: "native",
spoofMode: "native",
runInteractiveAuthMenu,
startOAuthServer: vi.fn(async () => ({ redirectUri: "http://localhost:1455/auth/callback" })),
waitForOAuthCallback: vi.fn(async () => {
throw new Error("callback failed")
}),
scheduleOAuthServerStop,
persistOAuthTokens,
openAuthUrl,
shutdownGraceMs: 1_000,
shutdownErrorGraceMs: 5_000
})

const payload = await authorize()
expect(payload.url).toBe("")
expect(payload.instructions).toBe("Login cancelled.")
expect(runInteractiveAuthMenu).toHaveBeenCalledTimes(1)
} finally {
stdin.isTTY = previousIn
stdout.isTTY = previousOut
}
})

it("returns a failed payload when browser oauth server startup fails", async () => {
const scheduleOAuthServerStop = vi.fn()
const persistOAuthTokens = vi.fn()
Expand Down Expand Up @@ -160,6 +201,62 @@ describe("createBrowserOAuthAuthorize", () => {
expect(persistOAuthTokens).not.toHaveBeenCalled()
expect(scheduleOAuthServerStop).not.toHaveBeenCalled()
})

it("skips the interactive auth menu when browser launch is disabled by env", async () => {
const scheduleOAuthServerStop = vi.fn()
const persistOAuthTokens = vi.fn(async () => {})
const openAuthUrl = vi.fn()
const runInteractiveAuthMenu = vi.fn<(options: { allowExit: boolean }) => Promise<"add" | "exit">>(
async () => "exit"
)

const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }
const stdout = process.stdout as NodeJS.WriteStream & { isTTY?: boolean }
const previousIn = stdin.isTTY
const previousOut = stdout.isTTY
const previousNoBrowser = process.env.OPENCODE_NO_BROWSER
stdin.isTTY = true
stdout.isTTY = true
process.env.OPENCODE_NO_BROWSER = "1"

try {
const authorize = createBrowserOAuthAuthorize({
authMode: "native",
spoofMode: "native",
runInteractiveAuthMenu,
startOAuthServer: vi.fn(async () => ({ redirectUri: "http://localhost:1455/auth/callback" })),
waitForOAuthCallback: vi.fn(async () => ({
refresh_token: "rt_no_browser",
access_token: "at_no_browser",
expires_in: 1200
})),
scheduleOAuthServerStop,
persistOAuthTokens,
openAuthUrl,
shutdownGraceMs: 1_000,
shutdownErrorGraceMs: 5_000
})

const payload = await authorize()
expect(runInteractiveAuthMenu).not.toHaveBeenCalled()
expect(payload.url).toContain("https://auth.openai.com/oauth/authorize")
await expect(payload.callback()).resolves.toMatchObject({
type: "success",
refresh: "rt_no_browser",
access: "at_no_browser"
})
expect(openAuthUrl).toHaveBeenCalledTimes(1)
expect(persistOAuthTokens).toHaveBeenCalledTimes(1)
} finally {
stdin.isTTY = previousIn
stdout.isTTY = previousOut
if (previousNoBrowser === undefined) {
delete process.env.OPENCODE_NO_BROWSER
} else {
process.env.OPENCODE_NO_BROWSER = previousNoBrowser
}
}
})
})

describe("createHeadlessOAuthAuthorize", () => {
Expand Down
Loading