Skip to content

add manual login mode for headless auth flows#132

Merged
ndycode merged 10 commits intomainfrom
git-plan/01-headless-auth
Mar 20, 2026
Merged

add manual login mode for headless auth flows#132
ndycode merged 10 commits intomainfrom
git-plan/01-headless-auth

Conversation

@ndycode
Copy link
Copy Markdown
Owner

@ndycode ndycode commented Mar 19, 2026

Summary

  • add a manual login path for environments where browser launch or the local callback listener is not usable

What Changed

  • add a manual auth mode that prints and copies the OAuth URL and accepts pasted callback input when interactive browser-based login is unavailable
  • keep the existing login flow intact for normal local usage while covering the manual path with CLI regressions around non-TTY and same-email workspace cases

Validation

  • npm run lint
  • npm run typecheck
  • npm test
  • npm test -- test/documentation.test.ts
  • npm run build

Docs and Governance Checklist

  • README updated (if user-visible behavior changed)
  • docs/getting-started.md updated (if onboarding flow changed)
  • docs/features.md updated (if capability surface changed)
  • relevant docs/reference/* pages updated (if commands/settings/paths changed)
  • docs/upgrade.md updated (if migration behavior changed)
  • SECURITY.md and CONTRIBUTING.md reviewed for alignment

Risk and Rollback

  • Risk level: Medium
  • Rollback plan: revert f82a748

Additional Notes

  • First PR in a 3-PR stack.
  • Validation was rerun on the top stacked branch after all three slices were integrated.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr introduces a --manual/--no-browser flag to codex auth login and a new isBrowserLaunchSuppressed() helper that reads CODEX_AUTH_NO_BROWSER/BROWSER env vars, enabling headless and browser-restricted auth flows. the core fix — gating startLocalOAuthServer on signInMode === "browser" only — correctly resolves the prior waitForCode indefinite-block in manual mode. promptManualCallback is extended with an allowNonTty path for stdin-pipe use. storage path state is refactored from module-level vars into a StoragePathState struct backed by AsyncLocalStorage.

  • runOAuthFlow now receives AuthLoginOptions and computes preferManualMode = options.manual || isBrowserLaunchSuppressed(), skipping server startup and the mode-picker prompt when true
  • isBrowserLaunchSuppressed uses a two-tier precedence: explicit CODEX_AUTH_NO_BROWSER (truthy values suppress, falsy values override even a disabling BROWSER setting) then BROWSER disabled-value check
  • setStoragePathState uses AsyncLocalStorage.enterWith() outside a run() scope, which permanently mutates the root async context — safe for single-command cli execution but worth watching in concurrent test workers or future server-mode usage
  • seven new regression tests cover the full manual-mode matrix: --manual skip-server, interactive picker manual choice, env-var suppressed fallback, non-tty stdin paste, state-mismatch rejection, eacces bind failure, and stdin-close graceful exit
  • docs, features.md, getting-started.md, commands.md, and upgrade.md are all updated consistently

Confidence Score: 4/5

  • safe to merge with one medium-priority concern in lib/storage.ts around enterWith context leakage
  • the manual login path is logically correct and well-covered by new regression tests. the prior blocking bug (waitForCode in manual mode) is properly fixed by gating server startup on signInMode === "browser". the isBrowserLaunchSuppressed logic is clean and the env-var precedence is documented. the main concern is AsyncLocalStorage.enterWith in setStoragePathState being called outside a run() scope, which leaks the context into the root async tree — this is fine for single-command cli use but is a fragile pattern that could cause subtle path mismatches in concurrent vitest workers or a hypothetical future server mode. no token safety or windows filesystem regressions introduced.
  • lib/storage.ts — the setStoragePathState function's use of enterWith outside a run() scope deserves a second look before this pattern is extended further

Important Files Changed

Filename Overview
lib/codex-manager.ts adds --manual/--no-browser flag parsing via parseAuthLoginArgs, passes AuthLoginOptions through runOAuthFlow; correctly gates startLocalOAuthServer on signInMode === "browser" only, fixing the prior waitForCode blocking bug; isReadlineClosedError contains a redundant post-instanceof guard
lib/auth/browser.ts adds isBrowserLaunchSuppressed() with two-tier env-var logic (CODEX_AUTH_NO_BROWSER takes precedence over BROWSER); asymmetry between the two sets is intentional and documented; windows powershell escaping and clipboard handling unchanged and correct
lib/storage.ts refactors module-level storage-path vars into a StoragePathState struct backed by AsyncLocalStorage; setStoragePathState uses enterWith outside a run() scope, which leaks the context into the root async tree — safe for single-command cli use but risky in concurrent test/worker scenarios; exportAccounts correctly guards transaction reuse by comparing storagePath
test/codex-manager-cli.test.ts solid new coverage: --manual skip-server, interactive mode-picker manual path, suppressed-browser fallback, non-tty stdin paste, state-mismatch rejection, windows eacces fallback, stdin-close graceful exit; one test stages a selectMock value that is never consumed due to preferManualMode bypassing promptOAuthSignInMode
test/browser.test.ts adds well-structured tests for isBrowserLaunchSuppressed covering truthy/falsy CODEX_AUTH_NO_BROWSER, BROWSER=none suppression, explicit false overriding disabling BROWSER, and combined-flag cases; env teardown in afterEach is correct
lib/ui/copy.ts adds callbackBypassed and callbackUnavailable UI strings for manual-mode messaging; straightforward, no issues
test/index.test.ts adds isBrowserLaunchSuppressed to the browser mock and covers the globally-suppressed auto-method path; assertions on flow.method, flow.instructions, and validate are correct

Sequence Diagram

sequenceDiagram
    participant CLI as codex auth login
    participant Parser as parseAuthLoginArgs
    participant OAuthFlow as runOAuthFlow
    participant BrowserUtil as isBrowserLaunchSuppressed
    participant Server as startLocalOAuthServer
    participant Browser as openBrowserUrl
    participant Prompt as promptManualCallback

    CLI->>Parser: args (e.g. ["--manual"])
    Parser-->>CLI: { ok: true, options: { manual: true } }
    CLI->>OAuthFlow: runOAuthFlow(forceNew, { manual: true })
    OAuthFlow->>BrowserUtil: isBrowserLaunchSuppressed()
    BrowserUtil-->>OAuthFlow: false (env not set)
    Note over OAuthFlow: preferManualMode = options.manual || suppressed = true
    OAuthFlow-->>OAuthFlow: signInMode = "manual" (skip promptOAuthSignInMode)
    Note over Server: startLocalOAuthServer NOT called
    OAuthFlow->>Browser: openBrowserUrl(url) — skipped (else branch)
    OAuthFlow-->>OAuthFlow: print URL + copy to clipboard
    Note over OAuthFlow: waitingForCallback = false (oauthServer is null)
    OAuthFlow->>Prompt: promptManualCallback(state, { allowNonTty: true })
    Prompt-->>OAuthFlow: code (from stdin or TTY paste)
    OAuthFlow-->>CLI: TokenResult { type: "success" }
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 1218-1226

Comment:
**Redundant type guard after `instanceof Error` check**

`error instanceof Error` already narrows `error` to `Error`, which is guaranteed to be a non-null object. the `typeof error === "object" && error !== null` in the ternary is therefore unreachable dead code — it will always evaluate to `true` at that point.

```suggestion
function isReadlineClosedError(error: unknown): boolean {
	if (!(error instanceof Error)) {
		return false;
	}
	const errorCode = "code" in error ? String((error as { code?: unknown }).code) : "";
	return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message);
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: lib/storage.ts
Line: 287-290

Comment:
**`AsyncLocalStorage.enterWith` called outside a `run()` scope — leaks into the root async context**

`enterWith` is designed to be called from within an existing `AsyncLocalStorage.run()` callback. when called at the module level (i.e. outside any `run()` scope), it mutates the root async context permanently and propagates the new state to every subsequent async operation spawned in the current event loop tick and beyond.

for a single-command cli process this is functionally safe — there is only one logical flow. but in the vitest test process, multiple tests share the same node.js async root. a `setStoragePathState` call in one test (e.g. `setStoragePath(null)`) will bleed its context into subsequent tests that happen to inherit the same async root, potentially causing subtle path-mismatch reads in `getStoragePath()` when `storagePathStateContext.getStore()` returns the wrong test's state instead of falling back to `currentStorageState`.

a safe alternative is to restructure callers to use `storagePathStateContext.run(state, callback)` so the context is explicitly scoped. if that restructuring is out of scope here, at minimum document the `enterWith` risk in a comment and ensure every test that mutates storage state explicitly resets it via `setStoragePath(null)` in an `afterEach`.

windows filesystem note: on windows, path comparison uses case-insensitive ntfs paths; if the leaked context carries a differently-cased path variant, `transactionState.storagePath === currentStoragePath` in `exportAccounts` could silently mismatch and trigger a redundant `loadAccountsInternal` read against the token file.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: test/codex-manager-cli.test.ts
Line: 3026-3028

Comment:
**`selectMock` staged but never consumed in suppressed-browser test**

`selectMock.mockResolvedValueOnce("browser")` is set up here, but since `isBrowserLaunchSuppressed` is mocked to return `true` on its first call, `preferManualMode` is `true` and `promptOAuthSignInMode()` (which drives `select`) is never invoked. the queued mock value is silently discarded.

this doesn't affect the assertion outcome, but it makes the test's intent ambiguous and may confuse future maintainers into thinking the interactive mode picker was exercised. consider either removing the `selectMock.mockResolvedValueOnce("browser")` line, or adding `expect(selectMock).not.toHaveBeenCalled()` to document the expectation explicitly.

missing vitest coverage note: there is no test covering the case where `isBrowserLaunchSuppressed()` returns `true` from the `BROWSER=none` env var path (without `CODEX_AUTH_NO_BROWSER`), verifying the full non-tty manual-fallback flow end-to-end.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "test(auth): consume ..."

Greptile also left 2 inline comments on this PR.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

the pr adds environment- and flag-driven non-browser oauth paths. it introduces isBrowserLaunchSuppressed() and honors --manual/--no-browser or CODEX_AUTH_NO_BROWSER to skip browser launches and drive a manual callback flow, plus docs and tests updated to cover these modes.

Changes

Cohort / File(s) Summary
browser suppression logic
lib/auth/browser.ts, test/browser.test.ts
added isBrowserLaunchSuppressed() and truthy/false-like value sets in lib/auth/browser.ts; updated openBrowserUrl() to return early when suppressed; tests extended to cover CODEX_AUTH_NO_BROWSER and BROWSER interactions (test/browser.test.ts).
oauth flow & cli integration
lib/codex-manager.ts, index.ts, lib/ui/copy.ts
threaded manual option through runOAuthFlow(..., options) and runAuthLogin(args) (lib/codex-manager.ts); index.ts now considers isBrowserLaunchSuppressed() when deciding manual mode; added ui copy strings callbackBypassed and callbackUnavailable (lib/ui/copy.ts).
storage / transaction state
lib/storage.ts
moved storage-path state into async-local storage and StoragePathState; updated exported-internal flows like withAccountStorageTransaction and exportAccounts() to respect transaction-scoped storagePath.
tests: cli & plugin
test/codex-manager-cli.test.ts, test/index.test.ts
expanded auth CLI tests for --manual, non-tty behavior, and suppression cases; added mock for isBrowserLaunchSuppressed() in test mocks and a plugin test that forces suppressed behavior (test/index.test.ts).
documentation
README.md, docs/*, docs/reference/commands.md, docs/upgrade.md
added docs and upgrade notes describing --manual/--no-browser flags, CODEX_AUTH_NO_BROWSER behavior, and stdin redirect-url usage for non-tty/manual shells.

Sequence Diagram(s)

sequenceDiagram
    actor user
    participant cli as cli (index.ts)
    participant authmgr as auth manager (lib/codex-manager.ts)
    participant browser as browser helper (lib/auth/browser.ts)
    participant oauthsrv as local oauth server
    participant prompt as manual prompt

    user->>cli: codex auth login [--manual]
    cli->>authmgr: runCodexMultiAuthCli(args)
    authmgr->>authmgr: parseAuthLoginArgs(args)
    authmgr->>browser: isBrowserLaunchSuppressed()
    browser-->>authmgr: true|false
    alt prefer manual (flag or suppressed)
        authmgr->>prompt: print oauth url, prompt/manual input
        prompt->>user: show url / accept pasted redirect
        user-->>prompt: paste redirect url
        prompt-->>authmgr: callback data
    else browser-first
        authmgr->>oauthsrv: startLocalOAuthServer()
        oauthsrv-->>authmgr: started | failed
        alt server started
            authmgr->>browser: openBrowserUrl(authUrl)
            browser-->>authmgr: opened|suppressed
            oauthsrv->>authmgr: callback received
        else server failed
            authmgr->>prompt: fallback to manual prompt
            prompt->>user: show url / accept pasted redirect
        end
    end
    authmgr->>authmgr: exchangeAuthorizationCode()
    authmgr-->>user: login complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

notes and flagged concerns:

  • concurrency risk: lib/codex-manager.ts:80 nullifies oauthServer on bind/start failure without retry or distinct error handling; concurrent auth attempts may silently fall back and confuse users.
  • windows/env edge cases: tests add CODEX_AUTH_NO_BROWSER coverage but BROWSER-based suppression on windows needs explicit regression tests (test/browser.test.ts added some, but ensure windows-specific pathext/opener behaviors covered).
  • missing regression tests: confirm full browser-first path still works when suppression is absent (no explicit end-to-end regression in test/codex-manager-cli.test.ts for the happy-path browser launch).
  • non-tty behavior: promptManualCallback() gating (allowNonTty) needs explicit unit test for the reject-path when allowNonTty: false (lib/codex-manager.ts changes exercised indirectly in test/..., but add a focused test).
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR description covers summary, what changed, validation steps, and risk/rollback plan. However, docs checklist items are unmarked despite file changes in README, docs/getting-started.md, docs/features.md, docs/reference/commands.md, docs/upgrade.md. Mark the completed docs checklist items (README, docs/getting-started.md, docs/features.md, docs/reference/commands.md, docs/upgrade.md) as [x] to reflect actual changes made. Confirm SECURITY.md and CONTRIBUTING.md review status.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed title follows conventional commit format with type 'add', scope 'manual login mode', and summary under 72 chars in lowercase imperative.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch git-plan/01-headless-auth
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch git-plan/01-headless-auth
📝 Coding Plan
  • Generate coding plan for human review comments

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread lib/codex-manager.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/reference/commands.md (1)

48-65: ⚠️ Potential issue | 🟠 Major

add upgrade-note and cross-doc parity for the new login flags.

docs/reference/commands.md:48-65 documents new user-visible login behavior, but this change set still leaves parity updates unchecked for README/getting-started/features/upgrade docs. ship these together so users get one consistent migration path.

As per coding guidelines, "docs/**: keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/commands.md` around lines 48 - 65, Update the docs to add an
upgrade note and ensure cross-document parity for the new login flags: add a
brief "Upgrade notes" section explaining the new codex auth login flags (e.g.,
--manual/--no-browser behavior and the deterministic non-TTY fallback driven by
CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON_RUN_AS_NODE), and update README,
getting-started, features, and upgrade docs to match docs/reference/commands.md
(including mention of the new flags and their effects on codex auth login and
non-TTY behavior); also add a line in the upgrade note that callers should be
aware of any new npm scripts or migration steps introduced for auth flows.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/auth/browser.ts`:
- Around line 24-32: The function isBrowserLaunchSuppressed incorrectly reuses
BROWSER_DISABLED_VALUES for CODEX_AUTH_NO_BROWSER, causing values like
"0"/"false"/"off" to be treated as suppression; change the explicitNoBrowser
check in isBrowserLaunchSuppressed to only treat explicit truthy values as
suppression (e.g., "1", "true", "yes", "on") instead of using
BROWSER_DISABLED_VALUES, or introduce a dedicated TRUE-like set (e.g.,
CODEX_AUTH_NO_BROWSER_TRUE_VALUES) and compare explicitNoBrowser against that;
keep the rest of the function (browserSetting check against
BROWSER_DISABLED_VALUES) unchanged and reference isBrowserLaunchSuppressed,
CODEX_AUTH_NO_BROWSER, and BROWSER_DISABLED_VALUES when making the change.

In `@lib/codex-manager.ts`:
- Around line 1471-1476: The catch around startLocalOAuthServer currently
swallows all errors; update the catch to log the caught error (including message
and stack) at debug/warn level before setting oauthServer = null so operators
can see why the local listener failed (e.g., port in use); reference
startLocalOAuthServer and the oauthServer variable and log with the existing
logger (e.g., processLogger or similar) so downstream
UI_COPY.oauth.callbackUnavailable still occurs but the root cause is recorded.
- Around line 1129-1144: The non-tty path in promptManualCallback (when
options.allowNonTty is true) calls rl.question("") which can wait indefinitely
on closed/empty stdin; update promptManualCallback to detect non-interactive
stdin and either (a) write a short stderr hint/notice before waiting (e.g.,
using console.error or output.write) describing expected piped input format, or
(b) immediately return null if stdin is closed/empty; also add a brief note to
docs/reference/commands.md describing the required stdin format when using
allowNonTty. Ensure changes reference promptManualCallback, options.allowNonTty
and the rl.question call so reviewers can find the fix.
- Line 12: Add a unit test that simulates Windows permission failures (EACCES or
EPERM) when startLocalOAuthServer attempts to bind the port and assert the code
handles it same as other bind failures: stub/mock startLocalOAuthServer to throw
an error with code "EACCES" or "EPERM" and OS set to "win32", run the auth flow
with isBrowserLaunchSuppressed behavior tested (and with CODEX_AUTH_NO_BROWSER
variations) and verify the CLI falls back or surfaces the expected permission
error path; reference the startLocalOAuthServer function and the
isBrowserLaunchSuppressed check in your test so the failure scenario is
explicitly covered.

In `@test/browser.test.ts`:
- Around line 76-82: Add deterministic vitest cases around
isBrowserLaunchSuppressed() and openBrowserUrl() to cover
CODEX_AUTH_NO_BROWSER="0" and "false" (these should NOT suppress; expect
isBrowserLaunchSuppressed() toBe(false) and openBrowserUrl(...) to call
mockedSpawn), BROWSER="none" (should suppress; expect toBe(true) and
openBrowserUrl(...) to be false and mockedSpawn not called), and both envs set
together to assert the actual precedence used by the implementation (write two
sub-cases: CODEX_AUTH_NO_BROWSER="1" + BROWSER="none" and
CODEX_AUTH_NO_BROWSER="0" + BROWSER="none")—each test should set/clear
process.env deterministically, call isBrowserLaunchSuppressed() and
openBrowserUrl("https://example.com"), and assert mockedSpawn call expectations
against the implementation behavior.

In `@test/codex-manager-cli.test.ts`:
- Around line 55-59: Add a deterministic test that flips the browser suppression
mock to true and asserts the auto-manual OAuth path: change the vi.mock for
isBrowserLaunchSuppressed to return true, stub openBrowserUrl and
startLocalOAuthServer (or its caller) to ensure they are not invoked, simulate
the user-pasted callback flow (use the same helper used elsewhere for pasted
callback verification) and assert token acquisition and clipboard behavior
(copyTextToClipboard) without relying on listeners or local server startup;
reference isBrowserLaunchSuppressed, openBrowserUrl, startLocalOAuthServer, and
copyTextToClipboard to locate the code to modify and the helper used for
pasted-callback assertions.
- Around line 2960-3005: Save and restore real TTY flags and assert the local
OAuth server was never used: before calling runCodexMultiAuthCli, capture and
set process.stdin.isTTY = true and process.stdout.isTTY = true (restore them
after the test) so the CLI behaves as a TTY, and after running, add an assertion
that vi.mocked(startLocalOAuthServer) (from serverModule / function
startLocalOAuthServer) was not called; keep the existing checks for
openBrowserUrl and storageState.accounts. Ensure you reference
runCodexMultiAuthCli, startLocalOAuthServer, and openBrowserUrl when making
these changes.

In `@test/index.test.ts`:
- Around line 564-580: The test for manual auth flow currently forces browser
suppression via browserModule.isBrowserLaunchSuppressed but doesn't assert the
browser open path wasn't used; update the test that calls autoMethod.authorize()
(from plugin.auth.methods[0]) to also mock and spy on
browserModule.openBrowserUrl (or the exported openBrowserUrl) and add an
assertion that openBrowserUrl was not called (e.g.,
expect(spy).not.toHaveBeenCalled()) to lock in the "no-browser" guarantee for
this path. Ensure you use vi.mocked(...) or a vitest spy for openBrowserUrl to
keep the test deterministic.

---

Outside diff comments:
In `@docs/reference/commands.md`:
- Around line 48-65: Update the docs to add an upgrade note and ensure
cross-document parity for the new login flags: add a brief "Upgrade notes"
section explaining the new codex auth login flags (e.g., --manual/--no-browser
behavior and the deterministic non-TTY fallback driven by
CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON_RUN_AS_NODE), and update README,
getting-started, features, and upgrade docs to match docs/reference/commands.md
(including mention of the new flags and their effects on codex auth login and
non-TTY behavior); also add a line in the upgrade note that callers should be
aware of any new npm scripts or migration steps introduced for auth flows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2e58a340-f62f-4196-9a8c-fc3b3e173b36

📥 Commits

Reviewing files that changed from the base of the PR and between b918aac and f82a748.

📒 Files selected for processing (8)
  • docs/reference/commands.md
  • index.ts
  • lib/auth/browser.ts
  • lib/codex-manager.ts
  • lib/ui/copy.ts
  • test/browser.test.ts
  • test/codex-manager-cli.test.ts
  • test/index.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/reference/commands.md
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/ui/copy.ts
  • lib/codex-manager.ts
  • lib/auth/browser.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/browser.test.ts
  • test/index.test.ts
  • test/codex-manager-cli.test.ts
🔇 Additional comments (8)
lib/codex-manager.ts (5)

314-343: parseAuthLoginArgs looks correct

argument parsing handles --manual, --no-browser, and --help/-h. returns early on unknown flags with a clear error message. the early return pattern for help ({ ok: false, message: "" }) is a bit unusual but handled correctly in runAuthLogin at lines 4169-4175.


1465-1470: manual mode routing logic is sound

preferManualMode correctly combines explicit options.manual with environment-based isBrowserLaunchSuppressed(). this covers both cli flag and headless detection paths. the downstream skip of promptOAuthSignInMode() at line 1479 is appropriate.


1514-1530: oauth server null-safety and close cleanup

the optional chaining on oauthServer?.ready and oauthServer?.close() correctly handles the null case from server startup failure. the fallback message via UI_COPY.oauth.callbackUnavailable provides user feedback when local listener is unavailable.


4166-4177: runAuthLogin parses and forwards options correctly

args are parsed via parseAuthLoginArgs, error handling is clean, and loginOptions is passed through to runOAuthFlow at line 4296. the empty-message check at line 4169 distinguishes help output from actual errors.

Also applies to: 4296-4296


4153-4153: explicit manual: false on refresh flow

passing { manual: false } explicitly in handleManageAction ensures refresh operations always use interactive mode regardless of environment flags. this is the correct behavior for dashboard-initiated refreshes.

lib/ui/copy.ts (1)

45-45: copy update is clear and safe.

lib/ui/copy.ts:45 adds the fallback message without exposing sensitive values or changing behavior.

lib/auth/browser.ts (1)

107-109: good early return before spawn.

lib/auth/browser.ts:107-109 cleanly blocks launcher side effects when browser usage is suppressed.

index.ts (1)

2389-2392: manual/no-browser mode gating is wired correctly.

index.ts:2389-2392 now routes --manual, --no-browser, and env suppression through one decision path, which keeps headless auth behavior deterministic.

Comment thread lib/auth/browser.ts
Comment thread lib/codex-manager.ts
Comment thread lib/codex-manager.ts Outdated
Comment thread lib/codex-manager.ts Outdated
Comment thread test/browser.test.ts
Comment thread test/codex-manager-cli.test.ts
Comment thread test/codex-manager-cli.test.ts
Comment thread test/index.test.ts
@ndycode ndycode added the passed label Mar 20, 2026
Comment thread lib/codex-manager.ts
Comment on lines +1218 to +1226
function isReadlineClosedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const errorCode =
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: "";
return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Redundant type guard after instanceof Error check

error instanceof Error already narrows error to Error, which is guaranteed to be a non-null object. the typeof error === "object" && error !== null in the ternary is therefore unreachable dead code — it will always evaluate to true at that point.

Suggested change
function isReadlineClosedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const errorCode =
typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: "";
return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message);
function isReadlineClosedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const errorCode = "code" in error ? String((error as { code?: unknown }).code) : "";
return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 1218-1226

Comment:
**Redundant type guard after `instanceof Error` check**

`error instanceof Error` already narrows `error` to `Error`, which is guaranteed to be a non-null object. the `typeof error === "object" && error !== null` in the ternary is therefore unreachable dead code — it will always evaluate to `true` at that point.

```suggestion
function isReadlineClosedError(error: unknown): boolean {
	if (!(error instanceof Error)) {
		return false;
	}
	const errorCode = "code" in error ? String((error as { code?: unknown }).code) : "";
	return errorCode === "ERR_USE_AFTER_CLOSE" || /readline was closed/i.test(error.message);
}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment thread lib/storage.ts
Comment on lines +287 to +290
function setStoragePathState(state: StoragePathState): void {
currentStorageState = state;
storagePathStateContext.enterWith(state);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 AsyncLocalStorage.enterWith called outside a run() scope — leaks into the root async context

enterWith is designed to be called from within an existing AsyncLocalStorage.run() callback. when called at the module level (i.e. outside any run() scope), it mutates the root async context permanently and propagates the new state to every subsequent async operation spawned in the current event loop tick and beyond.

for a single-command cli process this is functionally safe — there is only one logical flow. but in the vitest test process, multiple tests share the same node.js async root. a setStoragePathState call in one test (e.g. setStoragePath(null)) will bleed its context into subsequent tests that happen to inherit the same async root, potentially causing subtle path-mismatch reads in getStoragePath() when storagePathStateContext.getStore() returns the wrong test's state instead of falling back to currentStorageState.

a safe alternative is to restructure callers to use storagePathStateContext.run(state, callback) so the context is explicitly scoped. if that restructuring is out of scope here, at minimum document the enterWith risk in a comment and ensure every test that mutates storage state explicitly resets it via setStoragePath(null) in an afterEach.

windows filesystem note: on windows, path comparison uses case-insensitive ntfs paths; if the leaked context carries a differently-cased path variant, transactionState.storagePath === currentStoragePath in exportAccounts could silently mismatch and trigger a redundant loadAccountsInternal read against the token file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 287-290

Comment:
**`AsyncLocalStorage.enterWith` called outside a `run()` scope — leaks into the root async context**

`enterWith` is designed to be called from within an existing `AsyncLocalStorage.run()` callback. when called at the module level (i.e. outside any `run()` scope), it mutates the root async context permanently and propagates the new state to every subsequent async operation spawned in the current event loop tick and beyond.

for a single-command cli process this is functionally safe — there is only one logical flow. but in the vitest test process, multiple tests share the same node.js async root. a `setStoragePathState` call in one test (e.g. `setStoragePath(null)`) will bleed its context into subsequent tests that happen to inherit the same async root, potentially causing subtle path-mismatch reads in `getStoragePath()` when `storagePathStateContext.getStore()` returns the wrong test's state instead of falling back to `currentStorageState`.

a safe alternative is to restructure callers to use `storagePathStateContext.run(state, callback)` so the context is explicitly scoped. if that restructuring is out of scope here, at minimum document the `enterWith` risk in a comment and ensure every test that mutates storage state explicitly resets it via `setStoragePath(null)` in an `afterEach`.

windows filesystem note: on windows, path comparison uses case-insensitive ntfs paths; if the leaked context carries a differently-cased path variant, `transactionState.storagePath === currentStoragePath` in `exportAccounts` could silently mismatch and trigger a redundant `loadAccountsInternal` read against the token file.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/storage.ts (1)

1950-1963: ⚠️ Potential issue | 🟠 Major

pin transaction writes to the captured storage path.

lib/storage.ts:1950-1963 pins the read path in transaction state, but persist() still resolves the write target lazily through saveAccountsUnlocked() at lib/storage.ts:1801; the flagged variant has the same problem through lib/storage.ts:1977-2017 and lib/storage.ts:2251-2289. if a handler calls setStoragePath() or setStoragePathDirect() mid-transaction, this becomes a path-switch race: read path a, then write or roll back path b. exportAccounts() already guards the read side at lib/storage.ts:2356-2365; the transaction helpers should pin the write target too. i also do not see a storage regression alongside the auth coverage in test/codex-manager-cli.test.ts:3007-3043, test/browser.test.ts:76-82, and test/index.test.ts:564-580.

as per coding guidelines, lib/**: focus on auth rotation, windows filesystem io, and concurrency.

Also applies to: 1977-1980

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 1950 - 1963, The transaction helpers currently
capture the read path (storagePath = getStoragePath()) but persist() still
resolves the write target lazily via saveAccountsUnlocked(), allowing a
mid-transaction setStoragePath()/setStoragePathDirect() to change the write
target; fix by pinning the write path into the transaction state and making
persist() use that pinned path (i.e. call the save routine with the captured
storagePath or invoke an internal write helper that accepts the pinned path)
instead of calling saveAccountsUnlocked() with its own lookup; update the
persist closures in the transaction helpers (the persist function returned
alongside state.snapshot in transactionSnapshotContext.run and its flagged
variant) so all writes/rollbacks use state.storagePath, matching
exportAccounts()’s read-side guard.
♻️ Duplicate comments (1)
test/browser.test.ts (1)

123-136: ⚠️ Potential issue | 🔴 Critical

same issue — missing mockedExistsSync setup.

this test also expects openBrowserUrl to return true (line 130) but doesn't override the default mockedExistsSync.mockReturnValue(false) from beforeEach.

proposed fix
 		it("does not treat CODEX_AUTH_NO_BROWSER=false as suppression when BROWSER is disabled", () => {
 			Object.defineProperty(process, "platform", { value: "darwin" });
 			process.env.PATH = "/usr/bin";
 			process.env.CODEX_AUTH_NO_BROWSER = "false";
 			process.env.BROWSER = "none";
+			mockedExistsSync.mockImplementation(
+				(candidate) => typeof candidate === "string" && candidate.endsWith("open"),
+			);
+			mockedStatSync.mockReturnValue({
+				isFile: () => true,
+				mode: 0o755,
+			} as unknown as ReturnType<typeof fs.statSync>);

 			expect(isBrowserLaunchSuppressed()).toBe(false);
 			expect(openBrowserUrl("https://example.com")).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/browser.test.ts` around lines 123 - 136, The test "does not treat
CODEX_AUTH_NO_BROWSER=false as suppression when BROWSER is disabled" fails to
override the default mockedExistsSync behavior from beforeEach; before calling
isBrowserLaunchSuppressed() and openBrowserUrl() set
mockedExistsSync.mockReturnValue(true) so the path check passes and
openBrowserUrl can return true and invoke mockedSpawn; update the test to
explicitly set mockedExistsSync for this case (referencing mockedExistsSync,
isBrowserLaunchSuppressed, openBrowserUrl, and mockedSpawn).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/features.md`:
- Around line 57-59: The docs row about "Browser-first OAuth with manual
fallback" omits localhost callback-listener bind failures; update
docs/features.md to mention that manual/non-TTY login also covers local
callback-listener bind failures (when startLocalOAuthServer() is skipped in
manual mode in lib/codex-manager.ts and the bind-failure fallback exercised by
test/codex-manager-cli.test.ts:3043), and add/update corresponding upgrade notes
and README/SECURITY/docs entries to reflect the changed CLI workflows, flags
(`--manual`, `--no-browser`, CODEX_AUTH_NO_BROWSER) and any new npm scripts
introduced by this PR.

In `@docs/reference/commands.md`:
- Around line 59-67: Update the phrasing in the Upgrade Notes to state that
setting CODEX_AUTH_NO_BROWSER to any non-empty value (including "0" or "false")
overrides the BROWSER environment check and short-circuits the logic in
lib/auth/browser.ts (the early return that uses explicitNoBrowser and
NO_BROWSER_TRUTHY_VALUES), so operators know CODEX_AUTH_NO_BROWSER takes
precedence (see related behavior covered by test/browser.test.ts).

In `@lib/codex-manager.ts`:
- Line 4233: The dashboard re-login path currently calls runOAuthFlow(true, {
manual: false }) and ignores the session's loginOptions; change the call sites
inside handleManageAction (and the other path at the similar call near 4376) to
pass through the existing loginOptions object (e.g., runOAuthFlow(true,
loginOptions)) so the manual flag is honored; update handleManageAction
signature/ callers if needed to accept loginOptions so the same options are
threaded, and add a regression test beside the manual-flow coverage in
test/codex-manager-cli.test.ts (around 3007-3043) that verifies codex auth login
--manual keeps manual=true on refresh/re-login.

In `@README.md`:
- Around line 119-123: The README example instructs users to echo the callback
URL on the command line (which leaks secrets via shell history and process
listings); update the example to show feeding the callback URL on stdin from a
secure source (e.g., pipe the URL from a protected file or clipboard helper into
the CLI) instead of using echo with the URL literal. Change the line in
README.md and the duplicate in docs/getting-started.md to a secure stdin pattern
and note that lib/codex-manager.ts (accepting stdin at the handler around lines
1132-1207) will read the callback from stdin when using `codex auth login
--manual`.
- Around line 115-117: Add explicit Windows examples for setting the
CODEX_AUTH_NO_BROWSER env var to README.md and mirror them in
docs/getting-started.md: show both PowerShell and cmd ways to set
CODEX_AUTH_NO_BROWSER (matching the cross-platform toggle implemented in
lib/auth/browser.ts and the suppression behavior verified in
test/browser.test.ts) and ensure the CLI examples after setting the var reflect
the same `codex auth login` flows so README, docs, and tests remain consistent.

In `@test/browser.test.ts`:
- Around line 108-121: The tests calling openBrowserUrl() fail because
commandExists() uses fs.existsSync and the suite's beforeEach sets
mockedExistsSync.mockReturnValue(false), so override mockedExistsSync to return
true in the two tests that expect opener discovery; specifically, in the tests
that call isBrowserLaunchSuppressed() and openBrowserUrl() (the blocks at lines
around the open tests) add mockedExistsSync.mockReturnValue(true) before
invoking openBrowserUrl() so commandExists() finds the platform opener; apply
the same mockedExistsSync.mockReturnValue(true) fix to both test blocks that
expect open to return true (the test using CODEX_AUTH_NO_BROWSER="0" and the
test in the following block).

In `@test/codex-manager-cli.test.ts`:
- Around line 3220-3265: The test name says it should simulate a Windows-style
callback bind failure but currently doesn't; modify the test to mock the server
bind path (startLocalOAuthServer in the auth server module or whatever function
runCodexMultiAuthCli calls to start the local OAuth server) to reject/throw an
OSError-like error (e.g., with code "EACCES" or "EADDRINUSE") so the flow falls
back to manual input; keep the existing assertions (expect(promptQuestionMock)
called, openBrowserUrl not called, startLocalOAuthServer was attempted and
failed, and storageState.accounts length is 1) and reference
startLocalOAuthServer, runCodexMultiAuthCli, promptQuestionMock and
openBrowserUrlMock when adding the mock and verifying the fallback behavior.

---

Outside diff comments:
In `@lib/storage.ts`:
- Around line 1950-1963: The transaction helpers currently capture the read path
(storagePath = getStoragePath()) but persist() still resolves the write target
lazily via saveAccountsUnlocked(), allowing a mid-transaction
setStoragePath()/setStoragePathDirect() to change the write target; fix by
pinning the write path into the transaction state and making persist() use that
pinned path (i.e. call the save routine with the captured storagePath or invoke
an internal write helper that accepts the pinned path) instead of calling
saveAccountsUnlocked() with its own lookup; update the persist closures in the
transaction helpers (the persist function returned alongside state.snapshot in
transactionSnapshotContext.run and its flagged variant) so all writes/rollbacks
use state.storagePath, matching exportAccounts()’s read-side guard.

---

Duplicate comments:
In `@test/browser.test.ts`:
- Around line 123-136: The test "does not treat CODEX_AUTH_NO_BROWSER=false as
suppression when BROWSER is disabled" fails to override the default
mockedExistsSync behavior from beforeEach; before calling
isBrowserLaunchSuppressed() and openBrowserUrl() set
mockedExistsSync.mockReturnValue(true) so the path check passes and
openBrowserUrl can return true and invoke mockedSpawn; update the test to
explicitly set mockedExistsSync for this case (referencing mockedExistsSync,
isBrowserLaunchSuppressed, openBrowserUrl, and mockedSpawn).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c51ee175-024c-4814-a17e-46b08993128d

📥 Commits

Reviewing files that changed from the base of the PR and between f82a748 and 56ea657.

📒 Files selected for processing (12)
  • README.md
  • docs/features.md
  • docs/getting-started.md
  • docs/reference/commands.md
  • docs/upgrade.md
  • lib/auth/browser.ts
  • lib/codex-manager.ts
  • lib/storage.ts
  • lib/ui/copy.ts
  • test/browser.test.ts
  • test/codex-manager-cli.test.ts
  • test/index.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/ui/copy.ts
  • lib/auth/browser.ts
  • lib/storage.ts
  • lib/codex-manager.ts
docs/**

⚙️ CodeRabbit configuration file

keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

Files:

  • docs/features.md
  • docs/getting-started.md
  • docs/reference/commands.md
  • docs/upgrade.md
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/index.test.ts
  • test/codex-manager-cli.test.ts
  • test/browser.test.ts
🔇 Additional comments (13)
test/index.test.ts (2)

56-58: lgtm — mock setup for isBrowserLaunchSuppressed is correct.

default mock returns false, matching production behavior when no env suppression is set.


564-582: lgtm — past feedback addressed.

this test now correctly:

  • mocks isBrowserLaunchSuppressed to return true
  • asserts openBrowserUrl was not called (line 581)
  • validates manual flow selection via method: "code" and instruction text

deterministic and covers the suppressed-browser path as requested.

docs/upgrade.md (1)

52-62: lgtm — upgrade notes align with implementation.

documentation correctly describes:

  • browser-first default behavior
  • --manual / --no-browser flags
  • CODEX_AUTH_NO_BROWSER=1 suppression
  • stdin callback url for non-tty shells

cross-checked against lib/auth/browser.ts:11-33 — false-like values (0, false) are not in the truthy set, so they don't suppress. docs accurately reflect this.

test/browser.test.ts (2)

45-67: lgtm — env var save/restore prevents test pollution.

properly preserving and restoring CODEX_AUTH_NO_BROWSER and BROWSER in afterEach keeps tests deterministic across different ci environments.


79-85: lgtm — suppression regression cases address past feedback.

tests now cover:

  • CODEX_AUTH_NO_BROWSER="1" suppression (lines 79-85)
  • BROWSER="none" suppression (lines 138-144)
  • both truthy vars set (lines 146-153)

this locks in the no-browser guarantee for headless/automation paths.

Also applies to: 138-153

test/codex-manager-cli.test.ts (8)

44-51: mock aligns with test usage but is simpler than production.

the regex-based mock for parseAuthorizationInput handles URL-style code=...&state=... inputs, which is sufficient for the callback strings used in these tests. the real implementation at lib/auth/auth.ts:69-105 also handles hash fragments and plain code values, but those paths aren't exercised here.


56-59: default mock for isBrowserLaunchSuppressed looks correct.

returns false by default, allowing individual tests to override with mockReturnValueOnce(true) when testing the suppression path (e.g., lines 3119).


2960-3018: test covers --manual login without browser or local server usage.

verifies openBrowserUrl, startLocalOAuthServer, and waitForCode are all not called, and that the pasted callback flow persists an account. the tty state is explicitly pinned via setInteractiveTTY(true). addresses prior feedback about asserting the server isn't touched.


3020-3080: interactive manual selection path is covered.

ensures that when selectMock returns "manual", the flow skips browser launch and local server, then uses the pasted callback. good coverage for the interactive menu → manual flow.


3082-3131: browser-suppression fallback test addresses prior review gap.

flips isBrowserLaunchSuppressed to true and verifies pasted-callback handling without browser launch or listener dependence. this closes the coverage hole flagged in past comments.


3133-3178: non-tty manual mode test is deterministic and thorough.

sets isTTY to false, passes --manual, and asserts promptQuestionMock is called with an empty string (non-interactive prompt). verifies no browser or server usage, and account persistence.


3180-3218: state mismatch rejection test is solid.

verifies that a callback with state=wrong-state does not trigger exchangeAuthorizationCode and leaves storage unchanged. good edge-case coverage.


3267-3303: stdin close handling test is correct.

simulates promptQuestionMock rejecting with "readline was closed" and verifies graceful exit: no exchange, no persisted accounts, exit code 0. deterministic and useful for piped-stdin edge cases.

Comment thread docs/features.md
Comment on lines +57 to +59
| Browser-first OAuth with manual fallback | `codex auth login` stays browser-first, while `--manual`, `--no-browser`, and `CODEX_AUTH_NO_BROWSER=1` keep login usable in browser-restricted shells |

Manual/non-TTY login accepts the full callback URL on stdin, so automation and host-managed shells can complete auth without relying on a local browser handoff.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

mention callback-listener failures here too.

lib/codex-manager.ts:1540-1561 skips startLocalOAuthServer() in manual mode, and the bind-failure fallback is already exercised in test/codex-manager-cli.test.ts:3043. this row should mention localhost callback-listener failures as well, not only browser-restricted shells, otherwise the feature map under-documents the flow this pr adds.

as per coding guidelines, docs/**: keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/features.md` around lines 57 - 59, The docs row about "Browser-first
OAuth with manual fallback" omits localhost callback-listener bind failures;
update docs/features.md to mention that manual/non-TTY login also covers local
callback-listener bind failures (when startLocalOAuthServer() is skipped in
manual mode in lib/codex-manager.ts and the bind-failure fallback exercised by
test/codex-manager-cli.test.ts:3043), and add/update corresponding upgrade notes
and README/SECURITY/docs entries to reflect the changed CLI workflows, flags
(`--manual`, `--no-browser`, CODEX_AUTH_NO_BROWSER) and any new npm scripts
introduced by this PR.

Comment on lines +59 to +67
## Upgrade Notes

- `codex auth login` remains browser-first by default.
- `codex auth login --manual` and `codex auth login --no-browser` force the manual callback flow instead of launching a browser.
- `CODEX_AUTH_NO_BROWSER=1` suppresses browser launch for automation/headless sessions. False-like values such as `0` and `false` do not disable browser launch by themselves.
- In non-TTY/manual shells, pass the full redirect URL on stdin, for example: `echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual`.
- No new npm scripts or storage migration steps were introduced for this auth-flow update.

---
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

documentation phrasing may mislead operators on env precedence.

line 63 says "False-like values such as 0 and false do not disable browser launch by themselves."

per lib/auth/browser.ts:18-21, when CODEX_AUTH_NO_BROWSER is set to any non-empty value (including "0"), the function returns early:

if (explicitNoBrowser.length > 0) {
  return NO_BROWSER_TRUTHY_VALUES.has(explicitNoBrowser);
}

this means CODEX_AUTH_NO_BROWSER="0" returns false (browser allowed) and skips the BROWSER check entirely.

result: CODEX_AUTH_NO_BROWSER="0" + BROWSER="none" → browser launches (confirmed by test/browser.test.ts:108-136).

consider clarifying: "Setting CODEX_AUTH_NO_BROWSER to any value (including 0 or false) overrides the BROWSER environment variable check."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/commands.md` around lines 59 - 67, Update the phrasing in the
Upgrade Notes to state that setting CODEX_AUTH_NO_BROWSER to any non-empty value
(including "0" or "false") overrides the BROWSER environment check and
short-circuits the logic in lib/auth/browser.ts (the early return that uses
explicitNoBrowser and NO_BROWSER_TRUTHY_VALUES), so operators know
CODEX_AUTH_NO_BROWSER takes precedence (see related behavior covered by
test/browser.test.ts).

Comment thread lib/codex-manager.ts
if (!existing) return;

const tokenResult = await runOAuthFlow(true);
const tokenResult = await runOAuthFlow(true, { manual: false });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

carry the session's manual preference into account refresh.

lib/codex-manager.ts:4376 honors loginOptions, but lib/codex-manager.ts:4233 hardcodes { manual: false } for the dashboard re-login path. in a codex auth login --manual session, refresh/re-login account drops back to browser-first behavior instead of honoring the explicit flag. please thread the same loginOptions through handleManageAction(), and add a regression beside the existing manual-flow coverage in test/codex-manager-cli.test.ts:3007-3043.

suggested fix
-async function handleManageAction(
+async function handleManageAction(
 	storage: AccountStorageV3,
 	menuResult: Awaited<ReturnType<typeof promptLoginMode>>,
+	loginOptions: AuthLoginOptions,
 ): Promise<void> {
@@
-		const tokenResult = await runOAuthFlow(true, { manual: false });
+		const tokenResult = await runOAuthFlow(true, loginOptions);
-						await handleManageAction(currentStorage, menuResult);
+						await handleManageAction(currentStorage, menuResult, loginOptions);
@@
-						await handleManageAction(currentStorage, menuResult);
+						await handleManageAction(currentStorage, menuResult, loginOptions);

Also applies to: 4376-4376

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` at line 4233, The dashboard re-login path currently
calls runOAuthFlow(true, { manual: false }) and ignores the session's
loginOptions; change the call sites inside handleManageAction (and the other
path at the similar call near 4376) to pass through the existing loginOptions
object (e.g., runOAuthFlow(true, loginOptions)) so the manual flag is honored;
update handleManageAction signature/ callers if needed to accept loginOptions so
the same options are threaded, and add a regression test beside the manual-flow
coverage in test/codex-manager-cli.test.ts (around 3007-3043) that verifies
codex auth login --manual keeps manual=true on refresh/re-login.

Comment thread README.md
Comment on lines +115 to +117
codex auth login --manual
CODEX_AUTH_NO_BROWSER=1 codex auth login
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

document the windows syntax for CODEX_AUTH_NO_BROWSER.

lib/auth/browser.ts:25-32 makes CODEX_AUTH_NO_BROWSER a cross-platform toggle, and the suppression behavior is covered in test/browser.test.ts:76-82. the example here is posix-only, so windows users copying it from powershell or cmd will not actually set the variable. please add a powershell example here, and mirror it in docs/getting-started.md:61-64.

as per coding guidelines, docs/**: keep README, SECURITY, and docs consistent with actual CLI flags and workflows. whenever behavior changes, require updated upgrade notes and mention new npm scripts.

suggested fix
 codex auth login --manual
 CODEX_AUTH_NO_BROWSER=1 codex auth login
+$env:CODEX_AUTH_NO_BROWSER="1"; codex auth login
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 115 - 117, Add explicit Windows examples for setting
the CODEX_AUTH_NO_BROWSER env var to README.md and mirror them in
docs/getting-started.md: show both PowerShell and cmd ways to set
CODEX_AUTH_NO_BROWSER (matching the cross-platform toggle implemented in
lib/auth/browser.ts and the suppression behavior verified in
test/browser.test.ts) and ensure the CLI examples after setting the var reflect
the same `codex auth login` flows so README, docs, and tests remain consistent.

Comment thread README.md
Comment on lines +119 to +123
In non-TTY/manual shells, provide the full redirect URL on stdin instead of waiting for a browser callback:

```bash
echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

do not teach users to put the callback url on the command line.

echo "http://127.0.0.1:1455/auth/callback?code=..." | codex auth login --manual puts the live oauth code into shell history and makes it visible in process listings. lib/codex-manager.ts:1132-1207 already accepts stdin input, so the safer doc pattern is “feed the callback url on stdin from a secure source” rather than showing it as a command-line argument. the same example appears in docs/getting-started.md:66-70.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 119 - 123, The README example instructs users to echo
the callback URL on the command line (which leaks secrets via shell history and
process listings); update the example to show feeding the callback URL on stdin
from a secure source (e.g., pipe the URL from a protected file or clipboard
helper into the CLI) instead of using echo with the URL literal. Change the line
in README.md and the duplicate in docs/getting-started.md to a secure stdin
pattern and note that lib/codex-manager.ts (accepting stdin at the handler
around lines 1132-1207) will read the callback from stdin when using `codex auth
login --manual`.

Comment thread test/browser.test.ts
Comment on lines +108 to +121
it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.env.PATH = "/usr/bin";
process.env.CODEX_AUTH_NO_BROWSER = "0";
process.env.BROWSER = "none";

expect(isBrowserLaunchSuppressed()).toBe(false);
expect(openBrowserUrl("https://example.com")).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"open",
["https://example.com"],
{ stdio: "ignore", shell: false },
);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# run the specific test to verify it passes
cd "$(git rev-parse --show-toplevel)"
npm test -- test/browser.test.ts --reporter=verbose 2>&1 | head -100

Repository: ndycode/codex-multi-auth

Length of output: 178


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && wc -l test/browser.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 92


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '45,55p' test/browser.test.ts && echo "---" && sed -n '87,121p' test/browser.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1668


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '1,20p' test/browser.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 552


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '108,155p' test/browser.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 1837


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '138,160p' test/browser.test.ts

Repository: ndycode/codex-multi-auth

Length of output: 961


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '1,100p' lib/auth/browser.js | head -60

Repository: ndycode/codex-multi-auth

Length of output: 130


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && grep -n "existsSync\|openBrowserUrl\|isBrowserLaunchSuppressed" lib/auth/browser.js | head -20

Repository: ndycode/codex-multi-auth

Length of output: 120


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && find . -name "browser.*" -type f | grep -v node_modules | head -10

Repository: ndycode/codex-multi-auth

Length of output: 112


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && git ls-files | grep browser

Repository: ndycode/codex-multi-auth

Length of output: 108


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && wc -l lib/auth/browser.ts

Repository: ndycode/codex-multi-auth

Length of output: 91


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && cat -n lib/auth/browser.ts | head -100

Repository: ndycode/codex-multi-auth

Length of output: 4417


🏁 Script executed:

cd "$(git rev-parse --show-toplevel)" && sed -n '100,150p' lib/auth/browser.ts

Repository: ndycode/codex-multi-auth

Length of output: 1559


tests at 108-121 and 123-136 will fail: missing mockedExistsSync mock prevents opener discovery.

commandExists() at lib/auth/browser.ts:45-92 calls fs.existsSync() to check if the opener binary ("open" on darwin, "powershell.exe" on win32) exists on PATH. the beforeEach at test/browser.test.ts:50 sets mockedExistsSync.mockReturnValue(false) by default, so commandExists() always returns false unless overridden.

tests at lines 108-121 and 123-136 both call openBrowserUrl() expecting true but don't override the mock. they'll return false instead. the test at 87-106 correctly sets up the mock at lines 91-93—these two need the same pattern.

 		it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => {
 			Object.defineProperty(process, "platform", { value: "darwin" });
 			process.env.PATH = "/usr/bin";
 			process.env.CODEX_AUTH_NO_BROWSER = "0";
 			process.env.BROWSER = "none";
+			mockedExistsSync.mockImplementation(
+				(candidate) => typeof candidate === "string" && candidate.endsWith("open"),
+			);
+			mockedStatSync.mockReturnValue({
+				isFile: () => true,
+				mode: 0o755,
+			} as unknown as ReturnType<typeof fs.statSync>);
 
 			expect(isBrowserLaunchSuppressed()).toBe(false);
 			expect(openBrowserUrl("https://example.com")).toBe(true);

also apply the same fix to test/browser.test.ts:123-136.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.env.PATH = "/usr/bin";
process.env.CODEX_AUTH_NO_BROWSER = "0";
process.env.BROWSER = "none";
expect(isBrowserLaunchSuppressed()).toBe(false);
expect(openBrowserUrl("https://example.com")).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"open",
["https://example.com"],
{ stdio: "ignore", shell: false },
);
});
it("lets explicit false-like CODEX_AUTH_NO_BROWSER override a disabling BROWSER value", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.env.PATH = "/usr/bin";
process.env.CODEX_AUTH_NO_BROWSER = "0";
process.env.BROWSER = "none";
mockedExistsSync.mockImplementation(
(candidate) => typeof candidate === "string" && candidate.endsWith("open"),
);
mockedStatSync.mockReturnValue({
isFile: () => true,
mode: 0o755,
} as unknown as ReturnType<typeof fs.statSync>);
expect(isBrowserLaunchSuppressed()).toBe(false);
expect(openBrowserUrl("https://example.com")).toBe(true);
expect(mockedSpawn).toHaveBeenCalledWith(
"open",
["https://example.com"],
{ stdio: "ignore", shell: false },
);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/browser.test.ts` around lines 108 - 121, The tests calling
openBrowserUrl() fail because commandExists() uses fs.existsSync and the suite's
beforeEach sets mockedExistsSync.mockReturnValue(false), so override
mockedExistsSync to return true in the two tests that expect opener discovery;
specifically, in the tests that call isBrowserLaunchSuppressed() and
openBrowserUrl() (the blocks at lines around the open tests) add
mockedExistsSync.mockReturnValue(true) before invoking openBrowserUrl() so
commandExists() finds the platform opener; apply the same
mockedExistsSync.mockReturnValue(true) fix to both test blocks that expect open
to return true (the test using CODEX_AUTH_NO_BROWSER="0" and the test in the
following block).

Comment on lines +3220 to +3265
it("falls back to pasted manual input when Windows-style callback bind fails", async () => {
setInteractiveTTY(false);
const now = Date.now();
let storageState = {
version: 3 as const,
activeIndex: 0,
activeIndexByFamily: { codex: 0 },
accounts: [] as Array<Record<string, unknown>>,
};
loadAccountsMock.mockImplementation(async () => structuredClone(storageState));
saveAccountsMock.mockImplementation(async (nextStorage) => {
storageState = structuredClone(nextStorage);
});
promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" });
promptQuestionMock.mockResolvedValueOnce(
"http://127.0.0.1:1455/auth/callback?code=oauth-code&state=oauth-state",
);

const authModule = await import("../lib/auth/auth.js");
vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({
pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" },
state: "oauth-state",
url: "https://auth.openai.com/mock",
});
vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({
type: "success",
access: "access-eacces",
refresh: "refresh-eacces",
expires: now + 7_200_000,
idToken: "id-token-eacces",
multiAccount: true,
});

const browserModule = await import("../lib/auth/browser.js");
const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl);
const serverModule = await import("../lib/auth/server.js");

const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]);

expect(exitCode).toBe(0);
expect(promptQuestionMock).toHaveBeenCalledWith("");
expect(openBrowserUrlMock).not.toHaveBeenCalled();
expect(vi.mocked(serverModule.startLocalOAuthServer)).not.toHaveBeenCalled();
expect(storageState.accounts).toHaveLength(1);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

test name claims windows bind failure but no failure is simulated.

the test "falls back to pasted manual input when Windows-style callback bind fails" doesn't mock any bind error (e.g., EACCES, EADDRINUSE). it's functionally identical to the non-tty manual mode test above and doesn't exercise the bind-failure recovery path.

if the intent is to cover windows bind failures, simulate the error and verify the fallback:

suggested change to actually test bind failure
 it("falls back to pasted manual input when Windows-style callback bind fails", async () => {
-	setInteractiveTTY(false);
+	setInteractiveTTY(true);
 	const now = Date.now();
 	// ... storage setup ...

+	const serverModule = await import("../lib/auth/server.js");
+	vi.mocked(serverModule.startLocalOAuthServer).mockRejectedValueOnce(
+		makeErrnoError("bind EACCES 127.0.0.1:1455", "EACCES"),
+	);
+
 	// ... rest of test setup ...
 	const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
-	const exitCode = await runCodexMultiAuthCli(["auth", "login", "--manual"]);
+	const exitCode = await runCodexMultiAuthCli(["auth", "login"]);

 	expect(exitCode).toBe(0);
 	expect(promptQuestionMock).toHaveBeenCalled();
+	expect(vi.mocked(serverModule.startLocalOAuthServer)).toHaveBeenCalled();
 	// ... existing assertions ...
 });

as per coding guidelines, test/**: demand regression cases that reproduce windows filesystem behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/codex-manager-cli.test.ts` around lines 3220 - 3265, The test name says
it should simulate a Windows-style callback bind failure but currently doesn't;
modify the test to mock the server bind path (startLocalOAuthServer in the auth
server module or whatever function runCodexMultiAuthCli calls to start the local
OAuth server) to reject/throw an OSError-like error (e.g., with code "EACCES" or
"EADDRINUSE") so the flow falls back to manual input; keep the existing
assertions (expect(promptQuestionMock) called, openBrowserUrl not called,
startLocalOAuthServer was attempted and failed, and storageState.accounts length
is 1) and reference startLocalOAuthServer, runCodexMultiAuthCli,
promptQuestionMock and openBrowserUrlMock when adding the mock and verifying the
fallback behavior.

@ndycode ndycode merged commit 0f14eaf into main Mar 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant