Skip to content

Fix cross-process Linear OAuth refresh wiping valid connections#400

Open
cursor[bot] wants to merge 2 commits into
mainfrom
cursor/critical-correctness-bugs-0bf2
Open

Fix cross-process Linear OAuth refresh wiping valid connections#400
cursor[bot] wants to merge 2 commits into
mainfrom
cursor/critical-correctness-bugs-0bf2

Conversation

@cursor
Copy link
Copy Markdown
Contributor

@cursor cursor Bot commented May 29, 2026

Fixes ADE-63

Bug and impact

When ADE desktop and ade serve (headless runtime) are both active for the same project, a near-expiry Linear OAuth refresh can race. Linear rotates the refresh token on the first successful exchange; the second runtime retries with the now-invalid refresh token, gets invalid_grant, and clears the shared credential store — forcing the user to reconnect Linear even though the connection was still valid.

This was introduced by the automatic OAuth token refresh added in #395.

Root cause

  • ensureFreshToken() deduped refreshes in-process only (refreshInFlight).
  • Desktop and headless each use the shared EncryptedFileCredentialStore under .ade/secrets.
  • On invalid_grant, both services unconditionally cleared all Linear credentials.

Fix

  • Add a cross-process file lock (linear-oauth-refresh.lock) so only one runtime refreshes at a time.
  • Re-read the credential store after invalid_grant; if the refresh token was rotated or the access token is now fresh, treat the failure as a stale race and do not clear the connection.
  • Apply the same logic to desktop (linearCredentialService) and headless (headlessLinearServices).

Validation

  • npm run test -- --run src/main/services/cto/linearTokenRefresh.test.ts src/main/services/cto/linearAuth.test.ts (45 tests passed)
  • npm --prefix apps/ade-cli run test -- --run src/headlessLinearServices.test.ts (22 tests passed)
  • npm --prefix apps/desktop run typecheck
Open in Web View Automation 

ADE   Open in ADE  ·  cursor/critical-correctness-bugs-0bf2 branch  ·  PR #400

Greptile Summary

This PR fixes a cross-process race where ADE Desktop and ade serve could both attempt a Linear OAuth refresh concurrently, causing the slower process to receive invalid_grant (Linear rotates the refresh token on exchange) and unconditionally wipe the shared credential store.

  • Cross-process file lock (linear-oauth-refresh.lock) serialises refresh attempts across runtimes; the lock callback re-reads the credential store after acquisition so a proactive refresh that is no longer needed is skipped.
  • Stale-rotation recovery (linearInvalidGrantLikelyStaleRotation) detects that invalid_grant was caused by a peer rotation (different refresh token or fresh expiry already in the store) rather than a genuinely dead token, preventing unnecessary credential clearing.
  • Test hygiene fix in localRuntimeConnectionPool.test.ts adds isClosed: () => false to mock client objects to satisfy a new interface requirement unrelated to OAuth.

Confidence Score: 5/5

Safe to merge; the cross-process lock and stale-rotation recovery logic are well-reasoned and well-tested, with no regressions on the existing token-refresh paths.

The core race-condition fix is correct: the lock serialises cross-process refreshes and the re-read inside the lock prevents redundant work. The invalidGrant recovery logic is sound — it only treats the failure as stale when the store already holds a rotated token or a fresh expiry, and forced-refresh correctly opts out of the expiry heuristic. The two style-level findings do not affect normal operation.

No files require special attention; linearOAuthRefreshLock.ts and headlessLinearServices.ts have minor inconsistencies noted in comments but nothing that blocks correctness under normal conditions.

Important Files Changed

Filename Overview
apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts New cross-process file lock using wx flag + async sleep; mostly correct, but openSync/writeFileSync aren't atomic so a disk-full on writeFileSync leaks the fd and leaves an uncleaned lock file.
apps/desktop/src/main/services/cto/linearTokenRefresh.ts Adds linearInvalidGrantLikelyStaleRotation helper with clear semantics; trustFreshExpiresAt defaults to true (opt-out via false) which is correct and well-tested.
apps/desktop/src/main/services/cto/linearCredentialService.ts Lock wraps the full re-read + performRefresh inside the lock callback, with proper authMode/refreshToken re-validation after acquiring the lock; fallback (no credentialStore) preserves prior behavior.
apps/ade-cli/src/headlessLinearServices.ts Lock added and invalid_grant recovery logic ported; however the lock callback omits the authMode !== oauth re-check present in the desktop path, a minor inconsistency.
apps/desktop/src/main/services/cto/linearTokenRefresh.test.ts Good unit coverage of the new linearInvalidGrantLikelyStaleRotation function including forced-refresh and stale-store edge cases.
apps/desktop/src/main/services/cto/linearAuth.test.ts Two new integration tests cover the race-rotation happy path and the forced-refresh rejection; both scenarios are correctly validated.
apps/ade-cli/src/headlessLinearServices.test.ts New test covers forced-refresh invalid_grant without rotation (should clear), good parity with the desktop test suite.
apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts Mechanical test fix: adds isClosed: () => false to mock client objects to satisfy a new interface requirement; no logic changes.

Sequence Diagram

sequenceDiagram
    participant Desktop as ADE Desktop
    participant Headless as ade serve
    participant Lock as lock file
    participant Linear as Linear OAuth API
    participant Store as CredentialStore

    Note over Desktop,Headless: Token near expiry — both runtimes call ensureFreshToken()

    Desktop->>Lock: openSync wx — acquires lock
    Headless->>Lock: openSync wx — EEXIST, retries every 25ms

    Desktop->>Store: re-read refreshToken inside lock
    Desktop->>Linear: POST /oauth/token rt_v1
    Linear-->>Desktop: 200 OK rt_v2 + fresh expiresAt
    Desktop->>Store: write at_v2 / rt_v2 / expiresAt
    Desktop->>Lock: unlink — releases lock

    Headless->>Lock: openSync wx — acquires lock
    Headless->>Store: re-read refreshToken — sees rt_v2
    Note over Headless: expiresAt fresh, linearTokenNeedsRefresh=false — skip
    Headless->>Lock: unlink — releases lock

    Note over Desktop,Headless: Single refresh performed, connection preserved

    Note over Desktop,Headless: Fallback — invalid_grant recovery path
    Headless->>Linear: POST /oauth/token rt_v1
    Linear-->>Headless: 400 invalid_grant
    Headless->>Store: re-read store — sees rt_v2 or fresh expiresAt
    Note over Headless: linearInvalidGrantLikelyStaleRotation=true — skip clear
Loading

Fix All in Cursor Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts:47-55
If `fs.openSync` succeeds (setting `fd`) but `fs.writeFileSync` subsequently throws with a non-`EEXIST` error (e.g. `ENOSPC`), the catch block re-throws because `code !== "EEXIST"`. At that point `fd` is non-null and the lock file exists, but the outer `try/finally` that calls `closeSync`/`unlinkSync` has not been entered yet — so the fd is leaked and the lock file persists until `LOCK_STALE_MS` expires. Wrapping just the write in its own try so that any `writeFileSync` error still triggers the `finally` cleanup avoids this.

```suggestion
    try {
      fd = fs.openSync(lockPath, "wx");
      try {
        fs.writeFileSync(
          fd,
          JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
        );
      } catch {
        // Content is diagnostic-only; ignore write errors so we don't leak fd.
      }
    } catch (error) {
      const code = (error as NodeJS.ErrnoException).code;
      if (code !== "EEXIST") throw error;
```

### Issue 2 of 2
apps/ade-cli/src/headlessLinearServices.ts:1298-1308
The lock callback re-reads `refreshTokenKey` but does not re-verify `authModeKey === "oauth"` after acquiring the lock. The desktop path in `linearCredentialService.ts` explicitly guards `latest.authMode !== "oauth"` inside the lock. While the `!latestRefresh` check handles the most common case (credentials cleared), a state where a refresh token is present but auth mode was concurrently changed would silently proceed to `performRefresh`, producing an unnecessary network request. Mirroring the desktop guard keeps the two paths consistent.

```suggestion
        await withLinearOAuthRefreshLock(secretsDir, async () => {
          if (readCredential(authModeKey) !== "oauth") return;
          const latestRefresh = readCredential(refreshTokenKey);
          if (!latestRefresh) return;
          if (
            !opts?.force
            && !linearTokenNeedsRefresh(readCredential(tokenExpiresAtKey), Date.now())
          ) {
            return;
          }
          await performRefresh(latestRefresh);
        });
```

Reviews (2): Last reviewed commit: "fix: address Linear OAuth refresh review..." | Re-trigger Greptile

@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview May 29, 2026 8:40pm

@arul28 arul28 force-pushed the cursor/critical-correctness-bugs-0bf2 branch from 3c9fdca to 93a6ff3 Compare May 29, 2026 19:01
@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 29, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown
Contributor Author

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

PR Review

Scope: 7 file(s), +352 / −45
Verdict: Looks good

This PR fixes a real cross-process race: when desktop and ade serve refresh Linear OAuth near expiry, the loser’s invalid_grant was clearing a still-valid shared connection. It adds a file lock under .ade/secrets, re-reads credentials inside the lock, and treats invalid_grant as benign when the store already shows rotation or a fresh expiry (except on forced refresh after auth failure). Tests cover the race, forced refresh, and headless parity.


Notes

  • Tests: The concurrent-rotation and forced-refresh cases in linearAuth.test.ts and headlessLinearServices.test.ts match the failure mode described in the PR and give good regression coverage.
  • Lock helper: linearOAuthRefreshLock.ts follows the same wx + stale-mtime pattern as apps/ade-cli/src/tuiClient/state.ts; lock contention should be rare (only when two runtimes refresh at once).
  • Callers: linearClient already swallows ensureFreshToken errors, so a 15s lock timeout surfaces as a best-effort skip rather than a user-facing crash.
  • Docs (non-blocking): docs/features/cto/linear-integration.md still says invalid_grant always clears the connection; behavior is now conditional on stale rotation detection.
Open in Web View Automation 

Sent by Cursor Automation: BUGBOT in Versic

Comment thread apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts Outdated
Comment thread apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts Outdated
Comment thread apps/desktop/src/main/services/cto/linearOAuthRefreshLock.ts Outdated
@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 29, 2026

@copilot review but do not make fixes

@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 29, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 29, 2026

ADE-63

cursoragent and others added 2 commits May 29, 2026 16:39
When desktop and ade serve both refresh near token expiry, Linear rotates
the refresh token on the first exchange. The loser gets invalid_grant and
was clearing the shared credential store, forcing a full reconnect.

Serialize refresh with a cross-process lock under .ade/secrets and treat
invalid_grant as stale when the store already has a rotated refresh token
or a fresh access token.

Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
@arul28 arul28 force-pushed the cursor/critical-correctness-bugs-0bf2 branch from e6da089 to 392df60 Compare May 29, 2026 20:40
@arul28
Copy link
Copy Markdown
Owner

arul28 commented May 29, 2026

@copilot review but do not make fixes

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants