Skip to content
Closed
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
153 changes: 150 additions & 3 deletions lib/cache-lock.ts
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you split the unrelated lockfile changes into a new PR?

Original file line number Diff line number Diff line change
@@ -1,6 +1,151 @@
import fs from "node:fs/promises"
import path from "node:path"
import lockfile from "proper-lockfile"

type LockFunction = (file: string, options?: Record<string, unknown>) => Promise<() => Promise<void>>

type RetryOptions = {
retries: number
minTimeout: number
maxTimeout: number
}

let cachedLockFunction: LockFunction | undefined

function resolveLockFunction(value: unknown, visited = new Set<unknown>()): LockFunction | undefined {
if (typeof value === "function") {
return value as LockFunction
}
if (!value || typeof value !== "object") {
return undefined
}
if (visited.has(value)) {
return undefined
}
visited.add(value)

const record = value as Record<string, unknown>
const direct = resolveLockFunction(record.lock, visited)
if (direct) return direct

const viaDefault = resolveLockFunction(record.default, visited)
if (viaDefault) return viaDefault

const viaModule = resolveLockFunction(record.module, visited)
if (viaModule) return viaModule

const viaExports = resolveLockFunction(record.exports, visited)
if (viaExports) return viaExports

return undefined
}

function isFsErrorCode(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code
}

function toRetryOptions(value: unknown): RetryOptions {
const defaultOptions: RetryOptions = {
retries: 0,
minTimeout: 50,
maxTimeout: 200
}
if (!value || typeof value !== "object") {
return defaultOptions
}

const record = value as Record<string, unknown>
const retries =
typeof record.retries === "number" && Number.isFinite(record.retries)
? Math.max(0, Math.floor(record.retries))
: defaultOptions.retries
const minTimeout =
typeof record.minTimeout === "number" && Number.isFinite(record.minTimeout)
? Math.max(1, Math.floor(record.minTimeout))
: defaultOptions.minTimeout
const maxTimeout =
typeof record.maxTimeout === "number" && Number.isFinite(record.maxTimeout)
? Math.max(minTimeout, Math.floor(record.maxTimeout))
: defaultOptions.maxTimeout

return {
retries,
minTimeout,
maxTimeout
}
}

async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}

async function lockWithDirectoryFallback(
targetPath: string,
options?: Record<string, unknown>
): Promise<() => Promise<void>> {
const lockDir = `${targetPath}.lock`
const retry = toRetryOptions(options?.retries)
const staleMs = typeof options?.stale === "number" && Number.isFinite(options.stale) ? Math.max(1, options.stale) : 0

for (let attempt = 0; ; attempt += 1) {
try {
await fs.mkdir(lockDir)
return async () => {
try {
await fs.rm(lockDir, { recursive: true, force: true })
} catch (error) {
if (!isFsErrorCode(error, "ENOENT")) {
throw error
}
}
}
} catch (error) {
if (!isFsErrorCode(error, "EEXIST")) {
throw error
}

if (staleMs > 0) {
try {
const stat = await fs.stat(lockDir)
if (Date.now() - stat.mtimeMs > staleMs) {
await fs.rm(lockDir, { recursive: true, force: true })
continue
}
} catch (staleError) {
if (!isFsErrorCode(staleError, "ENOENT")) {
throw staleError
}
continue
}
}

if (attempt >= retry.retries) {
throw error
}

const timeout = Math.min(retry.maxTimeout, retry.minTimeout * 2 ** attempt)
await sleep(timeout)
}
}
}

async function resolveImportedLockFunction(specifier: string): Promise<LockFunction | undefined> {
try {
return resolveLockFunction(await import(specifier))
} catch {
return undefined
}
}

async function getLockFunction(): Promise<LockFunction> {
if (cachedLockFunction) return cachedLockFunction

cachedLockFunction =
(await resolveImportedLockFunction("proper-lockfile")) ??
(await resolveImportedLockFunction("proper-lockfile/index.js")) ??
lockWithDirectoryFallback

return cachedLockFunction
}

const LOCK_RETRIES = {
retries: 20,
Expand Down Expand Up @@ -41,7 +186,8 @@ export async function withLockedDirectory<T>(
options: LockTargetOptions = {}
): Promise<T> {
await fs.mkdir(directoryPath, { recursive: true })
const release = await lockfile.lock(directoryPath, resolveLockOptions(options))
const lock = await getLockFunction()
const release = await lock(directoryPath, resolveLockOptions(options))
try {
return await fn()
} finally {
Expand All @@ -57,7 +203,8 @@ export async function withLockedFile<T>(
await fs.mkdir(path.dirname(filePath), { recursive: true })
const lockTargetPath = lockTargetPathForFile(filePath)
await ensureLockTargetFile(lockTargetPath)
const release = await lockfile.lock(lockTargetPath, resolveLockOptions(options))
const lock = await getLockFunction()
const release = await lock(lockTargetPath, resolveLockOptions(options))
try {
return await fn()
} finally {
Expand Down
9 changes: 6 additions & 3 deletions lib/codex-native/oauth-auth-methods.ts
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auth fix looks right, but can you please add a regression test for the actual failing path: TTY browser auth when authorize() is called with no inputs argument? Current tests only cover authorize({}), which is likely why this regression still exists. Ideally, add one focused unit test in codex-native-oauth-auth-methods.test.ts and, if you want to be extra safe, one wiring-level test that calls browserMethod.authorize() with no args.

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
Loading