Skip to content

[3387 by Claude Opus 5.5] Renew Connect instance sign-in credentials through the relay (fixes #3382) - #3388

Merged
integry merged 4 commits into
mainfrom
3387/claude-opus-5-5-renew-connect-instance-si-20261029-1810-fbs
Oct 30, 2026
Merged

integry merged 4 commits into
mainfrom
3387/claude-opus-5-5-renew-connect-instance-si-20261029-1810-fbs

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

AI Implementation Summary

Closes #3387

Branch: 3387/claude-opus-5-5-renew-connect-instance-si-20261029-1810-fbs
Commits: ✅ Changes committed (d8ee3fa)


AI Processing Completed

Execution Details:

Summary:
I've implemented the instance side of #3387. Connect sign-in grants that the relay can renew are now renewed automatically instead of ending in missing_refresh_token. The new and existing grant/auth API suites pass (96 tests), as do the related UI tests, typecheck and lint. I haven't run any of it against a real relay, since the relay change ships separately.

What it does

  • Redemption: connectAuth.ts now reads access_token_expires_at and renewal: "relay" and stores them on the grant. A new migration adds renewal plus lease and backoff columns to github_user_grants. If a relay sends neither field, nothing changes. If the migration hasn't run yet, sign-in still works.
  • Proactive renewal: about 10 minutes before expiry, with up to 2 minutes of jitter, the grant is renewed through POST /v1/instance-auth/renew. The new token replaces the old one only if the stored credential hasn't changed, so a fresh browser sign-in always wins.
  • One renewal at a time: callers in the same process share one renewal. API, daemon and workers share a database lease on the grant row. I checked that the concurrency test fails when the lease is broken.
  • 401 handling: the existing forced-refresh paths (metadata routes, browser session sync, MCP) now renew once for these grants, and the caller retries once. Grants without renewal keep today's missing-refresh-token check.
  • Failures:
    • renewal_revoked, renewal_expired and reauth_required set the grant to reauth_required with that code as the reason.
    • renewal_unavailable keeps a still-valid token and drops back to today's behaviour. If the token was rejected or has expired, it requires sign-in with reason missing_refresh_token.
    • Network errors, 5xx and 429 back off (honouring Retry-After) and never mark the grant reauth_required.
  • Logging: each outcome is logged under [connect-grant-renewal] with only a hash of the grant id, the outcome, expiry, error code and next retry time. The tests check that no tokens or user ids appear.
  • UI: GITHUB_REAUTH_REQUIRED responses now include a reason field. The Desktop sign-in notice shows it as a bold first line, for example "Your GitHub authorization for Connect was revoked." I captured two preview screenshots of that notice.
  • Docs: there's a new "Connect browser sign-in renewal" section in docs/docs/operations/github-auth.md and a troubleshooting note pointing to it.

Things to know

  • Renewal needs traffic: it happens on the next use of the grant inside the renewal window; there is no background timer. If nothing uses the grant for hours, the token expires and the next use renews it first.
  • Expired token, relay down: I chose not to mark the grant reauth_required here. Callers get a retryable "temporarily unavailable" until the relay recovers, because forcing a sign-in would throw away a grant the relay can still renew. The issue would also allow marking it once the token has expired; this is easy to change if you prefer that.
  • Not covered: the MCP credential path in mcp/connect.ts is a separate flow and is unchanged.
  • Session-cookie errors: reason is only attached to the metadata-route errors that Desktop uses. Browser-session 401s still return the generic "Please log in again."
  • File splits: githubUserGrantService.ts was already at 399 of the 400-line lint limit. To stay under it I moved the token encryption helpers into githubUserGrantCrypto.ts. The renewal logic is in connectGrantRenewal.ts (relay call and error mapping) and connectGrantRenewer.ts (lease and storage).
  • Updated test: one existing assertion in githubMetadataAuth.test.ts now expects the added reason: 'missing_refresh_token'. The new tests are in packages/api/test/connectGrantRenewal.test.ts.

Detailed Logs:

  • Conversation: 71 messages
  • Session: ece3529b-0011-4a0f-b779-e2680e64caf0

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3387-2026-10-29T18-28-29-320Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3387-2026-10-29T18-28-29-320Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: I've implemented the instance side of #3387. Connect sign-in grants that the relay can renew are now renewed automatically instead of ending in `missing_refresh_token`. The new and existing grant/auth...


This PR was created automatically by ProPR after processing issue #3387.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!


Visual preview

Desktop GitHub sign-in notice: grant not renewable

Desktop GitHub sign-in notice: grant not renewable

For a grant issued before Connect renewal (renewal_unavailable → missing_refresh_token), the notice suggests signing in once to get a renewable authorization.

Original unavailable: managed storage is unavailable or the upload has expired.

Desktop GitHub sign-in notice: Connect authorization revoked

Desktop GitHub sign-in notice: Connect authorization revoked

The Desktop recovery notice now leads with the precise reason when the relay reports renewal_revoked. Rendered from the real component and desktop.css in headless Chromium.

Original unavailable: managed storage is unavailable or the upload has expired.

…hat the relay can renew are now renewed automatically instead of ending in `missing_refresh_token`. The new and existing grant/auth API suites pass (96 tests), as do the related UI tests, typecheck and lint. I haven't run any of it against a real relay, since the relay change ships separately.

What it does
- Redemption: `connectAuth.ts` now reads `access_token_expires_at` and `renewal: "relay"` and stores them on the grant. A new migration adds `renewal` plus lease and backoff columns to `github_user_grants`. If a relay sends neither field, nothing changes. If the migration hasn't run yet, sign-in still works.
- Proactive renewal: about 10 minutes before expiry, with up to 2 minutes of jitter, the grant is renewed through `POST /v1/instance-auth/renew`. The new token replaces the old one only if the stored credential hasn't changed, so a fresh browser sign-in always wins.
- One renewal at a time: callers in the same process share one renewal. API, daemon and workers share a database lease on the grant row. I checked that the concurrency test fails when the lease is broken.
- 401 handling: the existing forced-refresh paths (metadata routes, browser session sync, MCP) now renew once for these grants, and the caller retries once. Grants without `renewal` keep today's missing-refresh-token check.
- Failures:
  - `renewal_revoked`, `renewal_expired` and `reauth_required` set the grant to `reauth_required` with that code as the reason.
  - `renewal_unavailable` keeps a still-valid token and drops back to today's behaviour. If the token was rejected or has expired, it requires sign-in with reason `missing_refresh_token`.
  - Network errors, 5xx and 429 back off (honouring `Retry-After`) and never mark the grant `reauth_required`.
- Logging: each outcome is logged under `[connect-grant-renewal]` with only a hash of the grant id, the outcome, expiry, error code and next retry time. The tests check that no tokens or user ids appear.
- UI: `GITHUB_REAUTH_REQUIRED` responses now include a `reason` field. The Desktop sign-in notice shows it as a bold first line, for example "Your GitHub authorization for Connect was revoked." I captured two preview screenshots of that notice.
- Docs: there's a new "Connect browser sign-in renewal" section in `docs/docs/operations/github-auth.md` and a troubleshooting note pointing to it.

Things to know
- Renewal needs traffic: it happens on the next use of the grant inside the renewal window; there is no background timer. If nothing uses the grant for hours, the token expires and the next use renews it first.
- Expired token, relay down: I chose not to mark the grant `reauth_required` here. Callers get a retryable "temporarily unavailable" until the relay recovers, because forcing a sign-in would throw away a grant the relay can still renew. The issue would also allow marking it once the token has expired; this is easy to change if you prefer that.
- Not covered: the MCP credential path in `mcp/connect.ts` is a separate flow and is unchanged.
- Session-cookie errors: `reason` is only attached to the metadata-route errors that Desktop uses. Browser-session 401s still return the generic "Please log in again."
- File splits: `githubUserGrantService.ts` was already at 399 of the 400-line lint limit. To stay under it I moved the token encryption helpers into `githubUserGrantCrypto.ts`. The renewal logic is in `connectGrantRenewal.ts` (relay call and error mapping) and `connectGrantRenewer.ts` (lease and storage).
- Updated test: one existing assertion in `githubMetadataAuth.test.ts` now expects the added `reason: 'missing_refresh_token'`. The new tests are in `packages/api/test/connectGrantRenewal.test.ts`.
@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds durable relay renewal, coordinated requests, retry handling, and clearer sign-in guidance. It needs minor corrections before merge.

✅ Conditional credential updates — Renewal writes require the original encrypted credential and lease owner, protecting a newer browser sign-in.

✅ Recovery guidance — Recognized renewal failures propagate through the API to Desktop, while unknown reasons receive generic guidance.

The supplied current-head checks show 21 passed and no failures. This review uses static analysis only; no commands were run. The issue description is explicitly truncated, so unseen requirements were not assessed.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F1: 🔴 Preserve explicit non-expiring redemption

  • Required behavior: The redemption contract defines access_token_expires_at: null as non-expiring; an explicit expiry must take precedence over legacy relative expiry.

  • Evidence: packages/api/connectAuth.ts, tokenExpiresAt

    1. An additive redemption response includes access_token_expires_at: null alongside the legacy expires_in field.
    2. parseConnectExpiry returns null, but tokenExpiresAt only accepts numeric explicit values and falls back to expires_in.
    3. Capture persists a finite expiry for a non-expiring credential. Resolution consequently starts unnecessary renewal; during a relay outage, it eventually returns temporarily_unavailable despite the credential having no expiry.

    The parser correctly distinguishes null from absent metadata, but the caller discards that distinction. The non-expiring test omits expires_in, so it does not cover this sequence. Verification provenance: static trace.

  • Minimum fix: Return undefined immediately when the parsed explicit expiry is null; use expires_in only when explicit expiry is absent or malformed. Cover a response containing both fields.

F2: 🔴 Honor the full Retry-After interval

  • Required behavior: Transient renewal failures must honor the relay’s Retry-After; the documented behavior promises this explicitly.

  • Evidence: packages/api/connectGrantRenewal.ts, renewalBackoffMs

    1. A due grant receives HTTP 429 with Retry-After: 1800.
    2. The header parser produces 1,800,000 milliseconds, but renewalBackoffMs caps that value at 600,000 milliseconds.
    3. ConnectGrantRenewer.apply persists a retry time ten minutes away. A caller after that time sends another renewal request twenty minutes before the relay permits it.

    The database lease prevents simultaneous requests, but cannot enforce the discarded server deadline. The existing 429 test uses 120 seconds, below the cap. Verification provenance: static trace.

  • Minimum fix: Cap only the locally calculated exponential backoff, then take the maximum of that value and the full valid Retry-After interval. Add coverage for a server delay exceeding ten minutes.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Exercise shared preview integration

The production service injects visualPreviewOAuthCredentialService, whereas the new renewal tests omit it. resolveSharedGrant executes before relay renewal and can return a matching active credential immediately. Add an integration test covering proactive and forced renewal after both stores capture the same Connect login. The supplied excerpts do not establish exactly when the preview service returns an unchanged active grant, so this remains a verification follow-up rather than a demonstrated blocker.

Score

The implementation has useful concurrency protections and broad focused coverage, with passing current checks. Two localized contract errors remain before merge.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 8s
  • Tokens: 63,414 (62,103 in / 1,311 out)
  • Cost: $0.58

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F1 F2 S1

🤖 Review by ProPR

@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/fix

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 16ca62e

Addressed 1 AI review comment (IDs: 6510792421)

Addressed findings F1, F2

Summary of Changes

I fixed F1 and F2, and each now has a regression test. The renewal suite passes (20 tests), as do the connect-auth, metadata-auth and grant-rotation suites (28 tests); lint and typecheck are clean. To check the new tests, I ran them against the old source files and both failed, so they do catch the original bugs.

F1: explicit non-expiring redemption (packages/api/connectAuth.ts)

  • tokenExpiresAt now returns no expiry as soon as the relay sends access_token_expires_at: null. The legacy expires_in is only used when the explicit field is missing or malformed.
  • New test: a redemption carrying both expires_in: 300 and access_token_expires_at: null stores a null expiry. Resolving the grant then returns the token without calling the relay.

F2: full Retry-After interval (packages/api/connectGrantRenewal.ts)

  • renewalBackoffMs now caps only its own exponential backoff at 10 minutes. It then takes whichever is longer: that value or the relay's full Retry-After.
  • New test: a 429 with Retry-After: 1800 stores a retry time at least 30 minutes away. The test also checks the function directly: a 30-minute server delay is kept, and a high failure count alone still stays under the 10-minute cap.

Other places with the same mistake: none found.

  • The renew response parser already treats null as non-expiring, and treats a missing access_token_expires_at the same way.
  • ConnectGrantRenewer.apply is the only caller of renewalBackoffMs.
  • The docs say renewal "honours Retry-After", which is now true, so I didn't change them.

Nothing here changes what users see, so I made no preview.


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @integry
  • Time: 5m 16s
  • Tokens: 773,799 (768,685 in / 5,114 out)
  • Cost: $0.50

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6510825330✓

…renewal suite passes (20 tests), as do the connect-auth, metadata-auth and grant-rotation suites (28 tests); lint and typecheck are clean. To check the new tests, I ran them against the old source files and both failed, so they do catch the original bugs.

I fixed F1 and F2, and each now has a regression test. The renewal suite passes (20 tests), as do the connect-auth, metadata-auth and grant-rotation suites (28 tests); lint and typecheck are clean. To check the new tests, I ran them against the old source files and both failed, so they do catch the original bugs.

**F1: explicit non-expiring redemption** (`packages/api/connectAuth.ts`)
- `tokenExpiresAt` now returns no expiry as soon as the relay sends `access_token_expires_at: null`. The legacy `expires_in` is only used when the explicit field is missing or malformed.
- New test: a redemption carrying both `expires_in: 300` and `access_token_expires_at: null` stores a null expiry. Resolving the grant then returns the token without calling the relay.

**F2: full `Retry-After` interval** (`packages/api/connectGrantRenewal.ts`)
- `renewalBackoffMs` now caps only its own exponential backoff at 10 minutes. It then takes whichever is longer: that value or the relay's full `Retry-After`.
- New test: a 429 with `Retry-After: 1800` stores a retry time at least 30 minutes away. The test also checks the function directly: a 30-minute server delay is kept, and a high failure count alone still stays under the 10-minute cap.

**Other places with the same mistake:** none found.
- The renew response parser already treats `null` as non-expiring, and treats a missing `access_token_expires_at` the same way.
- `ConnectGrantRenewer.apply` is the only caller of `renewalBackoffMs`.
- The docs say renewal "honours `Retry-After`", which is now true, so I didn't change them.

Nothing here changes what users see, so I made no preview.

PR: #3388
Comment by: @integry (ID: 6510825330)
Model: Claude Opus 5.5
@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds durable relay renewal, cross-process coordination, retry backoff, and clearer reauthorization messages. It needs a localized concurrency correction before merge.

✅ Conditional credential replacement — Renewal writes check the original encrypted credential and lease owner, protecting a newer browser sign-in from an older renewal response.

✅ Useful recovery distinctions — Transient failures preserve active grants, while terminal relay codes become specific desktop recovery messages.

✅ Focused coverage — Added tests cover renewal timing, concurrent callers, newer sign-ins, legacy grants, and Retry-After handling.

This is a static review; no commands or tests were run. The supplied issue description is truncated, so requirements beyond its visible portion could not be assessed. Current checks show no failures and four pending checks.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F3: 🔴 Enforce backoff when acquiring the lease

  • Required behavior: Relay Retry-After and persisted renewal backoff must apply across processes sharing a grant.

  • Evidence: packages/api/connectGrantRenewer.ts, renewIfDue, renewWithLease, and apply.

    1. Processes A and B read the same due grant before either has persisted a retry deadline. B passes the retry check in renewIfDue.
    2. A acquires the lease and sends a renewal request. B is interrupted before its lease-acquisition UPDATE executes.
    3. A receives HTTP 429 with Retry-After: 1800. Its awaited apply UPDATE persists a thirty-minute retry deadline; its finally UPDATE releases the lease. The encrypted credential remains unchanged.
    4. B resumes its lease-acquisition UPDATE. The predicate checks the credential, active status, and lease expiration, but not the persisted retry deadline. All its predicates now match.
    5. B sends another renewal request immediately, violating the relay's thirty-minute refusal window. Its failure handling also calculates the failure count from its stale row, potentially replacing the deadline with a shorter backoff.

    The lease prevents overlapping holders but does not prevent this sequential acquisition using stale eligibility. The in-memory promise map cannot coordinate separate processes. The concurrency test holds the first request open while other callers attempt acquisition; the backoff tests use subsequent fresh reads, so neither exercises this ordering.

    static trace: verified from the retry check, lease-acquisition predicate, transient-result update, and lease release in the supplied code. proposed regression: pause B before acquisition, let A persist and release a 429 backoff, resume B, and assert there is only one relay call and the original retry deadline remains intact.

  • Minimum fix: Include a null-or-elapsed renewal_retry_after_ms condition in the atomic lease-acquisition UPDATE. A caller denied acquisition should reread the current grant and honor its persisted backoff.

Suggestions

These are optional follow-ups and are not sent to /fix.

S2: 🟢 Reject missing renewal expiry

packages/api/connectGrantRenewal.ts treats an omitted access_token_expires_at in a successful renewal response as explicit null. Consider classifying omission as invalid_response, preserving the distinction between malformed responses and genuinely non-expiring credentials. This is optional defensive validation against a relay response that violates the stated contract.

S3: 🟢 Exercise the production shared-store path

The new tests construct GitHubUserGrantService without the shared OAuth service, while the production singleton supplies it and calls resolveSharedGrant before relay renewal. An integration test capturing the same renewable login into both stores would help verify proactive renewal and forced recovery through that production ordering. The supplied context does not establish a defect in that path.

Score

The implementation is well scoped and has substantial focused coverage, but cross-process callers can bypass persisted relay backoff. Correct that race before merging; the remaining checks are still pending.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 8s
  • Tokens: 63,769 (62,482 in / 1,287 out)
  • Cost: $0.58

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F3 S2 S3

🤖 Review by ProPR

@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/fix

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 0886420

Addressed 1 AI review comment (IDs: 6511164588)

Addressed finding F3

Summary of Changes

I fixed F3: a backoff that one process saves now stops other processes from renewing the same grant, even if they read the grant before the backoff was saved.

Fix (packages/api/connectGrantRenewer.ts, renewWithLease)

  • The lease check now includes the retry deadline. The database update that takes the lease only succeeds if renewal_retry_after_ms is empty or has passed. This happens in the same atomic update as the credential, status and lease checks. In the reported sequence, B is refused, waits out any lease, and renewIfDue rereads the grant. B then keeps using the still-valid token, or gets temporarily_unavailable if the token has expired. It sends no second relay request, and the 30-minute deadline stays in place.
  • The failure count comes from the leased row. Without this, B could still shorten the backoff once a deadline had passed. Its stale row would show fewer failures than the database holds, so it would save a lower count and a shorter wait. The failure count is now read from the database after the lease is taken, so the backoff keeps growing.

Tests (packages/api/test/connectGrantRenewal.test.ts)

  • Stale read during a 429: a second process reads the due grant, then the first gets a 429 with Retry-After: 1800, saves the backoff and releases the lease. When the second process continues with its old row, the test checks that the relay was called once, the deadline is unchanged, the failure count is still 1, and the current token comes back.
  • Stale failure count: the old row shows 0 failures while the database holds 3 and the deadline has passed. After a 503, the test checks that 4 failures are saved with a future deadline.

Both new tests fail when run against the previous connectGrantRenewer.ts and pass with the fix. The renewal suite passes (22 tests), as do the connect-auth, metadata-auth and grant suites (23 tests). Lint with zero warnings and typecheck are clean for packages/api. These suites only run with NODE_ENV=test; without it they exit before any test runs.

I didn't find anything else outside F3 that needs reporting. Nothing in this change is visible in the UI, so there are no previews.


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @integry
  • Time: 5m 10s
  • Tokens: 861,337 (854,840 in / 6,497 out)
  • Cost: $0.54

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6511180274✓

…r processes from renewing the same grant, even if they read the grant before the backoff was saved.

I fixed F3: a backoff that one process saves now stops other processes from renewing the same grant, even if they read the grant before the backoff was saved.

**Fix** (`packages/api/connectGrantRenewer.ts`, `renewWithLease`)
- **The lease check now includes the retry deadline.** The database update that takes the lease only succeeds if `renewal_retry_after_ms` is empty or has passed. This happens in the same atomic update as the credential, status and lease checks. In the reported sequence, B is refused, waits out any lease, and `renewIfDue` rereads the grant. B then keeps using the still-valid token, or gets `temporarily_unavailable` if the token has expired. It sends no second relay request, and the 30-minute deadline stays in place.
- **The failure count comes from the leased row.** Without this, B could still shorten the backoff once a deadline had passed. Its stale row would show fewer failures than the database holds, so it would save a lower count and a shorter wait. The failure count is now read from the database after the lease is taken, so the backoff keeps growing.

**Tests** (`packages/api/test/connectGrantRenewal.test.ts`)
- **Stale read during a 429:** a second process reads the due grant, then the first gets a 429 with `Retry-After: 1800`, saves the backoff and releases the lease. When the second process continues with its old row, the test checks that the relay was called once, the deadline is unchanged, the failure count is still 1, and the current token comes back.
- **Stale failure count:** the old row shows 0 failures while the database holds 3 and the deadline has passed. After a 503, the test checks that 4 failures are saved with a future deadline.

Both new tests fail when run against the previous `connectGrantRenewer.ts` and pass with the fix. The renewal suite passes (22 tests), as do the connect-auth, metadata-auth and grant suites (23 tests). Lint with zero warnings and typecheck are clean for `packages/api`. These suites only run with `NODE_ENV=test`; without it they exit before any test runs.

I didn't find anything else outside F3 that needs reporting. Nothing in this change is visible in the UI, so there are no previews.

PR: #3388
Comment by: @integry (ID: 6511180274)
Model: Claude Opus 5.5
@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds durable relay renewal, coordinated renewal requests, retry backoff, and clearer Desktop recovery messages. It needs a localized correctness fix before merge.

✅ Conditional credential replacement — Renewal writes require both the original encrypted credential and lease owner, preventing an in-flight renewal from overwriting a newer browser sign-in.

✅ Persistent retry backoff — Lease acquisition checks the stored retry deadline, including when another process holds a stale grant snapshot.

✅ Scoped recovery messages — Desktop displays allowlisted reasons and falls back to generic guidance for unknown values.

The supplied checks report no failures and five pending checks. This review is static; no commands were run. The issue specification is explicitly truncated, so assessment is limited to its visible requirements and the supplied implementation.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F4: 🔴 Reject renewal responses missing expiry

  • Required behavior: The renewal contract distinguishes an explicit null expiry from an absent field. A malformed renewal response must not silently convert an expiring credential into a permanently valid stored grant.

  • Evidence: packages/api/connectGrantRenewal.ts:requestRelayRenewal, specifically body.access_token_expires_at === undefined ? null : ...; packages/api/connectGrantRenewer.ts:apply and renewIfDue.

    1. An expiring renewable grant reaches its renewal window.
    2. The relay returns HTTP 200 with a supported access_token but omits access_token_expires_at.
    3. requestRelayRenewal converts the missing field to null and classifies the response as successful.
    4. apply overwrites the known expiry with SQL NULL, clears backoff, and leaves the grant active.
    5. Subsequent ordinary resolutions never consider this credential due for renewal, even after the returned token expires. Consumers such as agent grant issuance receive it as active rather than obtaining renewal or temporary-unavailability handling.

    The explicit-null behavior is correct, but the missing-field conversion bypasses the existing invalid-response check. Forced renewal after a GitHub 401 can recover some callers; it does not preserve proactive renewal or protect consumers that only resolve the grant. static trace: verified from the supplied response parser, persistence, resolution, and agent-grant consumer. proposed regression: return a token-only success response and assert that the original expiry remains stored and transient backoff is recorded.

  • Minimum fix: Parse the field directly with parseConnectExpiry(body.access_token_expires_at) and classify undefined as invalid_response; retain explicit null as the non-expiring case.

Suggestions

These are optional follow-ups and are not sent to /fix.

S4: 🟢 Exercise the production shared-store integration

The new renewal tests construct GitHubUserGrantService without its shared OAuth dependency, while the production singleton supplies it and calls resolveSharedGrant before relay renewal. Add an integration case covering matching preview and durable Connect credentials, proactive renewal, and forced renewal. This would establish how the existing shared-store early returns interact with the new path; the supplied excerpts do not establish a separate blocker there.

Score

The implementation has useful concurrency safeguards and focused coverage, but malformed successful responses can disable expiry-driven renewal. The remaining checks are pending, with no reported failures.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 12s
  • Tokens: 65,608 (64,205 in / 1,403 out)
  • Cost: $0.60

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F4 S4

🤖 Review by ProPR

@integry

integry commented Oct 30, 2026

Copy link
Copy Markdown
Owner

/fix

@propr-dev

propr-dev Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 4d0d390

Addressed 1 AI review comment (IDs: 6511375798)

Addressed finding F4

Summary of Changes

I fixed F4: a renewal response that has no access_token_expires_at is now rejected instead of being saved as a token that never expires.

The fix (packages/api/connectGrantRenewal.ts, in requestRelayRenewal): the field is now passed straight to parseConnectExpiry. That function returns undefined when the field is absent, so the existing invalid_response check now catches it. An explicit null still means "non-expiring", as before. When the field is missing, the grant keeps its stored expiry, a transient backoff is recorded, and the grant stays active while the current token is still valid.

Regression test (packages/api/test/connectGrantRenewal.test.ts): I added "a success response missing access_token_expires_at" to the existing table of transient failures. That test already checks that the failure count goes up, a retry time is set, no second relay call happens during backoff, and a forced resolve returns temporarily_unavailable. I also added a check, run for every case in the table, that the original expiry is still stored.

Verification:

  • The renewal suite passes: 23 tests, 0 failures. I ran it with PROPR_DEMO_MODE=true because the file fails to load without GitHub auth configured, and that happened before my change too.
  • I ran the suite with the old version of connectGrantRenewal.ts and the new test failed, so it does catch the bug.
  • ESLint (--max-warnings 0) and tsc --noEmit for packages/api are clean.

Similar code checked: the only other caller of parseConnectExpiry is the redemption path in connectAuth.ts (tokenExpiresAt). There, a missing field is expected, because the issue says these fields are additive and older relays don't send them. In that case it falls back to expires_in rather than storing "never expires", so it doesn't have this bug and I didn't change it. The renewal endpoint is new and its response always includes access_token_expires_at, so a missing field there can only mean a malformed response.


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @integry
  • Time: 3m 33s
  • Tokens: 534,155 (530,563 in / 3,592 out)
  • Cost: $0.36

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6511502981✓

…pires_at` is now rejected instead of being saved as a token that never expires.

I fixed F4: a renewal response that has no `access_token_expires_at` is now rejected instead of being saved as a token that never expires.

**The fix** (`packages/api/connectGrantRenewal.ts`, in `requestRelayRenewal`): the field is now passed straight to `parseConnectExpiry`. That function returns `undefined` when the field is absent, so the existing `invalid_response` check now catches it. An explicit `null` still means "non-expiring", as before. When the field is missing, the grant keeps its stored expiry, a transient backoff is recorded, and the grant stays active while the current token is still valid.

**Regression test** (`packages/api/test/connectGrantRenewal.test.ts`): I added "a success response missing `access_token_expires_at`" to the existing table of transient failures. That test already checks that the failure count goes up, a retry time is set, no second relay call happens during backoff, and a forced resolve returns `temporarily_unavailable`. I also added a check, run for every case in the table, that the original expiry is still stored.

**Verification:**
- The renewal suite passes: 23 tests, 0 failures. I ran it with `PROPR_DEMO_MODE=true` because the file fails to load without GitHub auth configured, and that happened before my change too.
- I ran the suite with the old version of `connectGrantRenewal.ts` and the new test failed, so it does catch the bug.
- ESLint (`--max-warnings 0`) and `tsc --noEmit` for `packages/api` are clean.

**Similar code checked:** the only other caller of `parseConnectExpiry` is the redemption path in `connectAuth.ts` (`tokenExpiresAt`). There, a missing field is expected, because the issue says these fields are additive and older relays don't send them. In that case it falls back to `expires_in` rather than storing "never expires", so it doesn't have this bug and I didn't change it. The renewal endpoint is new and its response always includes `access_token_expires_at`, so a missing field there can only mean a malformed response.

PR: #3388
Comment by: @integry (ID: 6511502981)
Model: Claude Opus 5.5
@integry

integry commented Oct 30, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds durable relay renewal, database-backed coordination, transient-failure backoff, and specific reauthorization guidance. It appears merge-ready within the visible scope.

✅ Conditional credential updates — Renewal writes require the original encrypted credential and lease owner, protecting a newer browser sign-in from stale results.

✅ Retryable outage handling — Transient failures preserve active grants, persist backoff, and return temporary unavailability when the credential has expired.

✅ Focused coverage — Added tests cover concurrent renewal, stale backoff reads, terminal errors, and sign-in during renewal. Current-head checks report 21 passed and no failures or pending checks.

This was a static review; no commands or tests were run. The supplied issue description is truncated, so omitted acceptance criteria could not be assessed.

Merge blockers

No merge blockers.

Suggestions

These are optional follow-ups and are not sent to /fix.

S5: 🟢 Cover shared-preview integration

The production GitHubUserGrantService receives visualPreviewOAuthCredentialService, whereas the new renewal tests omit that dependency. In packages/api/githubUserGrantService.ts, resolveSharedGrant() can return before the relay-renewal branch. An integration test with matching preview and durable Connect grants would help verify proactive renewal, forced renewal, and outage handling through the production configuration. This is additional coverage, not a demonstrated blocker.

S6: 🟢 Prefer explicit error codes

In packages/api/connectGrantRenewal.ts, errorCode() selects a string error before checking code. A response containing a human-readable error and a stable code would therefore lose the stable classification. Consider testing and supporting that response shape if the relay permits it. The supplied contract does not establish that shape, so this is optional compatibility hardening.

Score

The implementation has substantial focused coverage and passing current-head checks, with no verified merge blocker in the supplied code. Production shared-preview integration would benefit from additional coverage.

Score: 8/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 37s
  • Tokens: 65,383 (64,244 in / 1,139 out)
  • Cost: $0.70

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix S5 S6

🤖 Review by ProPR

@integry
integry merged commit d952316 into main Oct 30, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Renew Connect instance sign-in credentials through the relay (fixes #3382)

1 participant