Skip to content

Fix Gemini OAuth: resolve multi-level symlink chain#497

Open
LeoLin990405 wants to merge 2 commits intosteipete:mainfrom
LeoLin990405:fix/gemini-oauth-symlink
Open

Fix Gemini OAuth: resolve multi-level symlink chain#497
LeoLin990405 wants to merge 2 commits intosteipete:mainfrom
LeoLin990405:fix/gemini-oauth-symlink

Conversation

@LeoLin990405
Copy link

Summary

  • Bug: Homebrew-installed gemini CLI has a 4-level symlink chain: /usr/local/bin/gemini/opt/homebrew/bin/gemini../Cellar/gemini-cli/0.32.1/bin/gemini../libexec/bin/gemini. CodexBar only resolves one level using destinationOfSymbolicLink, so it lands at /opt/homebrew/bin/ which lacks the libexec/ tree containing oauth2.js. This causes Gemini OAuth credential detection to fail silently.
  • Fix: Recursive symlink resolution that walks the entire chain, collecting all intermediate paths. It then tries each resolved directory (deepest first) to locate the OAuth credentials file, ensuring the correct libexec/ path is found regardless of how many symlink levels Homebrew uses.

Test plan

  • Tested with Homebrew gemini-cli 0.32.1 on macOS (4-level symlink chain)
  • Verified OAuth credentials are correctly located at the deepest resolved path
  • Confirmed backward compatibility with direct (non-symlinked) gemini installations

The Homebrew gemini binary has a 4-level symlink chain:
  /usr/local/bin/gemini → /opt/homebrew/bin/gemini
    → ../Cellar/gemini-cli/0.32.1/bin/gemini
    → ../libexec/bin/gemini → ...

The previous code only resolved one level, landing at
/opt/homebrew/bin which lacks the libexec/ tree.
Now collects all intermediate paths and tries each one,
finding oauth2.js from the Cellar-level resolution.
@ratulsarna
Copy link
Collaborator

Thanks for the fix. I tried to validate this on a Homebrew install here, and Gemini works for me on main, so I haven’t been able to reproduce the failure yet. Could you share the exact install layout/version and either repro steps or logs showing how the old path resolution fails? A which gemini, ls -l symlink chain for the binary, and the failing path lookup would be especially helpful.

@ratulsarna ratulsarna added the question Further information is requested label Mar 11, 2026
@LeoLin990405
Copy link
Author

Thanks for taking the time to investigate, @ratulsarna! After digging deeper, I found the root cause — it's specific to my setup but I think still worth considering.

Root Cause

I have a custom symlink at /usr/local/bin/gemini that points to the Homebrew binary, which adds an extra level to the symlink chain:

/usr/local/bin/gemini                          (custom symlink)
  → /opt/homebrew/bin/gemini                   (Homebrew link)
    → ../Cellar/gemini-cli/0.32.1/bin/gemini   (Cellar link)
      → ../libexec/bin/gemini                  (libexec link)
        → ../lib/node_modules/@google/gemini-cli/dist/index.js  (final target)

How to Reproduce

  1. Create the extra symlink (simulating a common PATH setup on Apple Silicon):

    ln -s /opt/homebrew/bin/gemini /usr/local/bin/gemini
  2. Make sure which gemini resolves to /usr/local/bin/gemini (i.e. /usr/local/bin appears before /opt/homebrew/bin in $PATH).

  3. The existing code calls destinationOfSymbolicLink once, resolving:

    /usr/local/bin/gemini → /opt/homebrew/bin/gemini
    
  4. It then computes:

    binDir  = /opt/homebrew/bin
    baseDir = /opt/homebrew          ← wrong, should be .../Cellar/gemini-cli/0.32.1
    
  5. All candidate paths (e.g. baseDir/libexec/lib/…/oauth2.js) end up under /opt/homebrew/libexec/… which doesn't exist → OAuth detection silently fails.

Why You Can't Reproduce

If which gemini returns /opt/homebrew/bin/gemini directly (no extra /usr/local/bin symlink), the single-level resolution lands at .../Cellar/gemini-cli/0.32.1/bin/gemini, and from there baseDir is correct — so everything works fine.

My Take

I understand this is an edge case caused by my own setup. That said, users who put /usr/local/bin earlier in their PATH (or use wrapper scripts that symlink to Homebrew) could hit the same issue. If you'd prefer a simpler fix, even just replacing destinationOfSymbolicLink with Foundation's resolvingSymlinksInPath() (which resolves the full chain in one call) would handle this case. Happy to simplify the PR if that approach is preferred — or feel free to close it if you think it's too niche. Totally understand either way! 🙂

@ratulsarna
Copy link
Collaborator

Thanks, this helps. I could reproduce Gemini working on main with a normal Homebrew install, so I wasn’t seeing the issue before.

Your explanation makes sense though: this only breaks when gemini is reached through an extra symlink like /usr/local/bin/gemini -> /opt/homebrew/bin/gemini, which means the current code resolves too shallowly and builds the wrong base path.

That sounds like a real edge case, not a general Homebrew issue. We're fine with supporting it, but can you add a regression test for this exact setup before we merge and ensure lint and all tests pass?

Extract resolveOAuthFileContent(from:) as an internal static method so
it can be tested directly. Add four test cases:

1. Standard 2-level Homebrew symlink chain (baseline)
2. 3-level chain with extra /usr/local/bin symlink (the bug scenario)
3. Non-symlinked binary (direct npm install)
4. Missing oauth2.js returns nil gracefully

Tests create real symlinks in a temp directory to exercise the actual
FileManager.destinationOfSymbolicLink resolution loop.
@LeoLin990405
Copy link
Author

Hi @steipete,

Thank you for the thoughtful review and for requesting regression tests — fully agreed that this kind of symlink resolution logic should be well covered. I've added a dedicated test suite (GeminiOAuthSymlinkTests.swift) with four tests that exercise the full resolveOAuthFileContent(from:) code path using real symlinks in temporary directories (no mocking).


Regression Tests Added

Test Scenario What it validates
findsOAuthWithStandardHomebrewChain() Standard 2-level Homebrew symlink chain Guards against regressions in the common case
findsOAuthWithExtraSymlinkLevel() The original bug scenario — 3-level chain: /usr/local/bin/gemini/opt/homebrew/bin/gemini../Cellar/…/bin/gemini../libexec/bin/gemini Verifies the fix works for the exact layout that caused the issue
handlesNonSymlinkBinary() Direct npm install (no symlinks at all) Ensures the resolver still works when the binary isn't a symlink
returnsNilWhenOAuthFileMissing() Binary exists but oauth2.js is absent Confirms graceful nil return with no crashes

Each test creates a complete directory tree mimicking the real Homebrew Cellar layout (including multi-level relative symlinks like ../Cellar/gemini-cli/0.32.1/bin/gemini) in a uniquely-named temp directory, and cleans up via defer.

To make this testable, I extracted the symlink resolution + file search logic into an internal static method resolveOAuthFileContent(from:) on GeminiStatusProbe, accessible via @testable import CodexBarCore. The method's behavior is identical to what was previously inlined in extractOAuthCredentials().


CI Results

All 4 regression tests pass on macOS-latest in GitHub Actions (Run #23041932123):

◇ Suite GeminiOAuthSymlinkTests started.
◇ Test findsOAuthWithStandardHomebrewChain() started.
✔ Test findsOAuthWithStandardHomebrewChain() passed after 0.005 seconds.
◇ Test findsOAuthWithExtraSymlinkLevel() started.
✔ Test findsOAuthWithExtraSymlinkLevel() passed after 0.005 seconds.
◇ Test handlesNonSymlinkBinary() started.
✔ Test handlesNonSymlinkBinary() passed after 0.002 seconds.
◇ Test returnsNilWhenOAuthFileMissing() started.
✔ Test returnsNilWhenOAuthFileMissing() passed after 0.001 seconds.
✔ Suite GeminiOAuthSymlinkTests passed after 0.015 seconds.

Screenshot — GeminiOAuthSymlinkTests (4/4 passed):

CI Symlink Tests Pass

Screenshot — Overall test summary:

CI Test Summary


Note on the Single Unrelated Failure

The CI run shows 919 out of 920 tests passed. The sole failure is SettingsStoreTests.providerOrder_persistsAndAppendsNewProviders(), which is not related to this PR.

Root cause: The CI ran on a branch that also includes new provider registrations (.qwen, .doubao, .zenmux, .aigocode, .trae from a separate PR). The test's hardcoded expected provider list at SettingsStoreTests.swift:726 doesn't include these new entries, causing a list length mismatch (27 actual vs 22 expected).

Why this cannot be caused by PR #497: This PR only modifies two files — GeminiStatusProbe.swift (refactoring existing logic into a testable method) and the new GeminiOAuthSymlinkTests.swift. It does not add, remove, or modify any UsageProvider cases, provider registration, or ordering logic.

Screenshot — Failure analysis:

CI Unrelated Failure


Please let me know if there's anything else you'd like me to adjust. Thank you for maintaining such a high quality bar for CodexBar!

@ratulsarna
Copy link
Collaborator

@codex review

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Chef's kiss.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants