Skip to content

fix(desktop): re-mint terminal JWT on every reconnect - #5408

Open
saddlepaddle wants to merge 1 commit into
mainfrom
fix/terminal-token-remint-on-reconnect
Open

saddlepaddle wants to merge 1 commit into
mainfrom
fix/terminal-token-remint-on-reconnect

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Remote-workspace terminals fail to attach while everything else (files, PRs, git, ports) works. Relay logs show /hosts/<org>:<machine>/terminal/<id>?token=… returning HTTP 401 in ~0–1 ms at the relay edge, at a ~71% rate across multiple users' hosts (128× 401 vs 10× 200 on a live sample). The same host gets both 200 and 401 → a per-token rejection, not a per-host/version issue.

Root cause

The terminal WebSocket URL has a 1-hour better-auth JWT baked into it at React render time (useWorkspaceWsUrl → getWsToken()), and the desktop transport stores that full URL in transport.currentUrl. Every reconnect path — reconnectNow, scheduleReconnect, handleResume — redials transport.currentUrl verbatim, never re-minting the token. Once the JWT passes its exp, the relay (validating via better-auth JWKS) rejects the attach with 401. tRPC/filesystem calls are unaffected because they mint a fresh token per request.

The module-level token refresh (setJwt every 50 min) updates a plain variable that doesn't trigger a re-render, so the baked URL never recomputes.

Trigger: the sleep/wake + focus/visibility auto-reconnect machinery added in #5135 (and hardened in #5137) redials aggressively using the token-baked URL, converting a previously-latent staleness bug into a fleet-wide 401 storm. Surfacing via the recent canary / 1.12.6 builds. The relay validator and JWT signing config are unchanged — this is not a server/secret problem.

Fix

Stop persisting the token in currentUrl; mint it fresh per dial:

  • terminal-ws-transport.ts: store a token-less base URL (stripUrlToken) and append a freshly-minted token at each dial — both the WS open and the fly-affinity pre-flight — via a new optional getToken provider on connect().
  • terminal-runtime-registry.ts: thread getToken through connect()/reconnect(); compare against the stripped URL so a token refresh alone doesn't tear down a live shell.
  • TerminalPane.tsx: pass getWsToken (from useWorkspaceClient()) as the provider.

This mirrors what web's TerminalConnection.buildUrl() and desktop's tRPC headers already do (fresh token per request).

Verification

  • bun test terminal-ws-transport.test.ts → 7 pass
  • biome check → clean
  • Typecheck: no errors from the changed files (a pre-existing duplicate-@codemirror dedup error in CodeEditor.tsx is unrelated and reproduces on main without this change).

Immediate mitigation (no release needed)

Affected users can fully quit & reopen the desktop app (or reopen the terminal) to force a fresh token.


Open in Stage

Summary by cubic

Re-mint the terminal JWT on every reconnect in the desktop app to stop 401s after token expiry. Store a token‑less base URL and add a fresh token for each dial (including pre-flight).

  • Bug Fixes
    • Store a token-less currentUrl; append a fresh token per (re)dial in terminal-ws-transport (WS open and relay-affinity pre-flight).
    • Add optional getToken to connect(); thread through terminal-runtime-registry so reconnects use new tokens.
    • Compare against stripped URLs to avoid tearing down a live shell when only the token changes.
    • Pass getWsToken from useWorkspaceClient() in TerminalPane (@superset/workspace-client).

Written for commit 2a35f66. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Terminal connections now refresh authentication tokens automatically during reconnects.
    • Reconnects are smarter and can reuse existing sessions when only the token changes.
  • Bug Fixes

    • Reduced unnecessary terminal disconnects caused by token updates.
    • Improved WebSocket reconnection reliability for terminal sessions.

Terminal WebSocket attaches bake a 1h JWT into the connection URL at
render time; every reconnect path (reconnectNow, scheduleReconnect,
handleResume) replayed that same URL verbatim, so once the token passed
its 1h exp the relay rejected the attach with 401 while all other
host-service calls (which mint a fresh token per request) kept working.
The aggressive sleep/wake + focus/visibility auto-reconnect added in
#5135 turned this latent staleness into a fleet-wide ~71% terminal-401
storm.

Store a token-less base URL in the transport and append a freshly-minted
token at each dial (socket open and the affinity pre-flight) via a
getWsToken provider threaded through the registry, mirroring how web's
buildUrl() and desktop tRPC headers already refresh per request.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Terminal WebSocket connect/reconnect now supports an optional getToken callback for minting a fresh authentication token on each dial. A stripUrlToken helper normalizes URLs for token-agnostic comparison during reconnect eligibility checks. TerminalPane supplies a token-fetcher ref sourced from useWorkspaceClient.

Changes

Token-aware terminal reconnection

Layer / File(s) Summary
Transport: token stripping and per-dial token minting
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Adds exported stripUrlToken(), stores a token-less currentUrl and _getToken callback on TerminalTransport, builds a buildDialUrl() closure to append a fresh token per dial, and uses the token-appended URL for /hosts/ pre-flight affinity priming.
Runtime registry: getToken propagation and reconnect eligibility
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
connect and reconnect accept an optional getToken callback forwarded to the transport; reconnect eligibility compares entry.transport.currentUrl against stripUrlToken(wsUrl) so token-only URL changes no longer force a reconnect.
TerminalPane: fresh token fetcher wiring
apps/desktop/.../TerminalPane/TerminalPane.tsx
Captures getWsToken from useWorkspaceClient() into getWsTokenRef and passes it to both terminalRuntimeRegistry.connect and .reconnect calls.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TerminalPane
  participant TerminalRuntimeRegistry
  participant TerminalTransport
  participant WebSocket

  TerminalPane->>TerminalRuntimeRegistry: connect/reconnect(terminalId, wsUrl, getWsTokenRef.current)
  TerminalRuntimeRegistry->>TerminalRuntimeRegistry: stripUrlToken(wsUrl) vs currentUrl
  alt URL unchanged (token-only diff)
    TerminalRuntimeRegistry-->>TerminalPane: reuse existing connection
  else URL changed
    TerminalRuntimeRegistry->>TerminalTransport: connect(transport, terminal, wsUrl, getToken)
    TerminalTransport->>TerminalTransport: buildDialUrl() with fresh token
    TerminalTransport->>WebSocket: open(dialUrl)
  end
Loading

Possibly related PRs

  • superset-sh/superset#5135: Both PRs modify the reconnect flow in terminal-ws-transport.ts, with #5135 adding liveness-driven reconnects and this PR making those reconnects token-aware.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: refreshing terminal JWTs on reconnect.
Description check ✅ Passed The description is mostly complete, covering problem, root cause, fix, and verification, though it skips some template sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 fix/terminal-token-remint-on-reconnect

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.

@ghost

ghost commented Jul 1, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Enable token-less URL tracking in terminal transport
2 Thread token provider through runtime registry
3 Wire fresh token provider to TerminalPane
Open in Stage

Chapters generated by Stage for commit 2a35f66 on Jul 1, 2026 6:08pm UTC.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a fleet-wide 401 storm on terminal WebSocket reconnects by stopping the relay-rejecting pattern of replaying a stale JWT baked into currentUrl at render time. Instead, transport.currentUrl now stores only the token-less base URL and a _getToken provider on the transport mints a fresh JWT at every dial — including the fly-affinity pre-flight — mirroring what tRPC headers and the web client already do.

  • terminal-ws-transport.ts: adds stripUrlToken(), stores baseUrl in currentUrl, introduces _getToken on the transport interface, and calls buildDialUrl() (which invokes _getToken()) inside both the pre-flight and openSocket to get a fresh token each time.
  • terminal-runtime-registry.ts: threads getToken through connect()/reconnect() and fixes the idempotency guard to compare against the stripped URL, preventing a token refresh alone from tearing down a live shell.
  • TerminalPane.tsx: sources getWsToken from useWorkspaceClient() via a ref and passes it to both the mount connect() and the URL-change reconnect() effects.

Confidence Score: 4/5

The fix is sound and directly addresses the root cause; the main risk is a subtle token-provider lifetime issue in TerminalPane that could resurface stale-token behavior under workspace-client rebuilds.

The transport-layer changes are clean and correct — _getToken persists on the transport so internal reconnects always mint a fresh JWT. The React wiring in TerminalPane.tsx passes getWsTokenRef.current (a snapshot of the function at call time) rather than a stable wrapper that delegates through the ref, so transport._getToken can become stale if the workspace client is rebuilt with a new getWsToken closure and the URL hasn't changed. Under normal usage this is unlikely to fire, but it is structurally the same issue this PR is solving. The disconnect/disposeTransport cleanup omissions are minor.

TerminalPane.tsx — the getWsTokenRef.current call sites where a stable useCallback wrapper would be more robust.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts Core fix: strips token from currentUrl, introduces _getToken provider on the transport, and mints fresh JWTs via buildDialUrl() at every dial including the pre-flight affinity request. disconnect() and disposeTransport() omit clearing _getToken, leaving a dangling closure reference after teardown.
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts Threads the optional getToken provider through connect()/reconnect() and updates the idempotency guard to compare against the stripped URL so a token-only change doesn't tear down a live shell. Changes are minimal and correct.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx Wires getWsToken from useWorkspaceClient() into both connect() and reconnect() via a ref. Passes getWsTokenRef.current (a function snapshot) instead of a stable wrapper that delegates through the ref, so transport._getToken can become stale if the workspace client is rebuilt without a URL change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant TP as TerminalPane
    participant TRR as TerminalRuntimeRegistry
    participant TWT as TerminalWsTransport
    participant Relay as Relay/Edge

    Note over TP: Mount effect
    TP->>TRR: connect(terminalId, wsUrl, instanceId, getWsTokenRef.current)
    TRR->>TWT: connect(transport, terminal, wsUrl, getToken)
    TWT->>TWT: stripUrlToken(wsUrl) → baseUrl
    TWT->>TWT: "transport._getToken = getToken"
    TWT->>TWT: "transport.currentUrl = baseUrl"
    TWT->>TWT: buildDialUrl() → appends fresh JWT to baseUrl
    TWT->>Relay: primeRelayAffinity(buildDialUrl())
    Relay-->>TWT: affinity set
    TWT->>Relay: new WebSocket(buildDialUrl()) ← fresh token

    Note over TWT: 1h later, JWT expires...
    Note over TWT: Sleep/wake triggers reconnectNow()
    TWT->>TWT: connect(transport, terminal, transport.currentUrl)
    TWT->>TWT: "getToken=undefined → _getToken unchanged"
    TWT->>TWT: buildDialUrl() → transport._getToken() → fresh JWT
    TWT->>Relay: new WebSocket(buildDialUrl()) ← NEW fresh token

    Note over TP: baseWebsocketUrl changes
    TP->>TRR: reconnect(terminalId, wsUrl, instanceId, getWsTokenRef.current)
    TRR->>TRR: stripUrlToken(wsUrl) vs transport.currentUrl
    alt URL unchanged
        TRR-->>TP: return (no teardown)
    else URL changed
        TRR->>TWT: connect(transport, terminal, wsUrl, getToken)
        TWT->>TWT: "transport._getToken = getToken (updated)"
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant TP as TerminalPane
    participant TRR as TerminalRuntimeRegistry
    participant TWT as TerminalWsTransport
    participant Relay as Relay/Edge

    Note over TP: Mount effect
    TP->>TRR: connect(terminalId, wsUrl, instanceId, getWsTokenRef.current)
    TRR->>TWT: connect(transport, terminal, wsUrl, getToken)
    TWT->>TWT: stripUrlToken(wsUrl) → baseUrl
    TWT->>TWT: "transport._getToken = getToken"
    TWT->>TWT: "transport.currentUrl = baseUrl"
    TWT->>TWT: buildDialUrl() → appends fresh JWT to baseUrl
    TWT->>Relay: primeRelayAffinity(buildDialUrl())
    Relay-->>TWT: affinity set
    TWT->>Relay: new WebSocket(buildDialUrl()) ← fresh token

    Note over TWT: 1h later, JWT expires...
    Note over TWT: Sleep/wake triggers reconnectNow()
    TWT->>TWT: connect(transport, terminal, transport.currentUrl)
    TWT->>TWT: "getToken=undefined → _getToken unchanged"
    TWT->>TWT: buildDialUrl() → transport._getToken() → fresh JWT
    TWT->>Relay: new WebSocket(buildDialUrl()) ← NEW fresh token

    Note over TP: baseWebsocketUrl changes
    TP->>TRR: reconnect(terminalId, wsUrl, instanceId, getWsTokenRef.current)
    TRR->>TRR: stripUrlToken(wsUrl) vs transport.currentUrl
    alt URL unchanged
        TRR-->>TP: return (no teardown)
    else URL changed
        TRR->>TWT: connect(transport, terminal, wsUrl, getToken)
        TWT->>TWT: "transport._getToken = getToken (updated)"
    end
Loading

Comments Outside Diff (2)

  1. apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts, line 554-561 (link)

    P2 disconnect() zeros out currentUrl and _terminal but leaves _getToken set. A transport that has been disconnected and then passed to a fresh connect() call without a getToken argument will silently reuse the old provider. Clearing it on disconnect makes the field's semantics consistent with the rest of the teardown and avoids an unintended closure reference keeping the old workspace client alive.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
    Line: 554-561
    
    Comment:
    `disconnect()` zeros out `currentUrl` and `_terminal` but leaves `_getToken` set. A transport that has been disconnected and then passed to a fresh `connect()` call without a `getToken` argument will silently reuse the old provider. Clearing it on disconnect makes the field's semantics consistent with the rest of the teardown and avoids an unintended closure reference keeping the old workspace client alive.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts, line 594-602 (link)

    P2 disposeTransport() clears every internal field (_terminal, currentUrl, all timers, all listener sets) but not _getToken. This leaves a closure reference alive until the transport object is GC'd. Adding a null assignment keeps the teardown symmetrical with disconnect() and makes the dispose semantics complete.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
    Line: 594-602
    
    Comment:
    `disposeTransport()` clears every internal field (`_terminal`, `currentUrl`, all timers, all listener sets) but not `_getToken`. This leaves a closure reference alive until the transport object is GC'd. Adding a `null` assignment keeps the teardown symmetrical with `disconnect()` and makes the dispose semantics complete.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx:155-156
**Stale `getToken` provider after workspace-client rebuild**

`getWsTokenRef.current` is passed by value at call time, so `transport._getToken` captures the `getWsToken` function instance from the mount render. If `useWorkspaceClient()` ever returns a new `getWsToken` reference that closes over refreshed credentials (e.g., after a workspace-client rebuild without a URL change), auto-reconnects (`reconnectNow`, `scheduleReconnect`) will still call the old function. The reconnect effect only updates `_getToken` when `stripUrlToken(wsUrl)` changes — a pure credential-context change with a stable URL leaves `_getToken` pointing at the stale provider, which is exactly the class of bug this PR is fixing.

Passing a stable wrapper that delegates through the ref prevents this — use `useCallback(() => getWsTokenRef.current(), [])` and pass that stable function to both `connect()` and `reconnect()`. `transport._getToken` then stores one stable function for the transport's lifetime, and every dial calls through to whatever `getWsToken` is current at reconnect time.

### Issue 2 of 3
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts:554-561
`disconnect()` zeros out `currentUrl` and `_terminal` but leaves `_getToken` set. A transport that has been disconnected and then passed to a fresh `connect()` call without a `getToken` argument will silently reuse the old provider. Clearing it on disconnect makes the field's semantics consistent with the rest of the teardown and avoids an unintended closure reference keeping the old workspace client alive.

```suggestion
	transport.currentUrl = null;
	transport._terminal = null;
	transport._getToken = null;
	transport._reconnectAttempt = 0;
	setTerminalTitle(transport, undefined);
	setConnectionState(transport, "disconnected");
	transport.onDataDisposable?.dispose();
	transport.onDataDisposable = null;
}
```

### Issue 3 of 3
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts:594-602
`disposeTransport()` clears every internal field (`_terminal`, `currentUrl`, all timers, all listener sets) but not `_getToken`. This leaves a closure reference alive until the transport object is GC'd. Adding a `null` assignment keeps the teardown symmetrical with `disconnect()` and makes the dispose semantics complete.

```suggestion
	transport._writeCoalescer?.dispose();
	transport._writeCoalescer = null;
	transport.currentUrl = null;
	transport._terminal = null;
	transport._getToken = null;
	transport._reconnectAttempt = 0;
	setTerminalTitle(transport, undefined);
	transport.onDataDisposable?.dispose();
	transport.onDataDisposable = null;
	transport.stateListeners.clear();
```

Reviews (1): Last reviewed commit: "fix(desktop): re-mint terminal JWT on ev..." | Re-trigger Greptile

Comment on lines +155 to 156
getWsTokenRef.current,
);

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.

P1 Stale getToken provider after workspace-client rebuild

getWsTokenRef.current is passed by value at call time, so transport._getToken captures the getWsToken function instance from the mount render. If useWorkspaceClient() ever returns a new getWsToken reference that closes over refreshed credentials (e.g., after a workspace-client rebuild without a URL change), auto-reconnects (reconnectNow, scheduleReconnect) will still call the old function. The reconnect effect only updates _getToken when stripUrlToken(wsUrl) changes — a pure credential-context change with a stable URL leaves _getToken pointing at the stale provider, which is exactly the class of bug this PR is fixing.

Passing a stable wrapper that delegates through the ref prevents this — use useCallback(() => getWsTokenRef.current(), []) and pass that stable function to both connect() and reconnect(). transport._getToken then stores one stable function for the transport's lifetime, and every dial calls through to whatever getWsToken is current at reconnect time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx
Line: 155-156

Comment:
**Stale `getToken` provider after workspace-client rebuild**

`getWsTokenRef.current` is passed by value at call time, so `transport._getToken` captures the `getWsToken` function instance from the mount render. If `useWorkspaceClient()` ever returns a new `getWsToken` reference that closes over refreshed credentials (e.g., after a workspace-client rebuild without a URL change), auto-reconnects (`reconnectNow`, `scheduleReconnect`) will still call the old function. The reconnect effect only updates `_getToken` when `stripUrlToken(wsUrl)` changes — a pure credential-context change with a stable URL leaves `_getToken` pointing at the stale provider, which is exactly the class of bug this PR is fixing.

Passing a stable wrapper that delegates through the ref prevents this — use `useCallback(() => getWsTokenRef.current(), [])` and pass that stable function to both `connect()` and `reconnect()`. `transport._getToken` then stores one stable function for the transport's lifetime, and every dial calls through to whatever `getWsToken` is current at reconnect time.

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

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts`:
- Around line 433-434: Guard token minting on the pre-flight relay path by
moving the buildDialUrl() call out of the eager primeRelayAffinity(...) argument
in terminal-ws-transport.ts; if _getToken can throw, it should be handled
through the existing openSocket flow so /hosts/ connections still reach the
reconnect/error path instead of staying stuck in "connecting". Update the
needsPreFlight branch to defer URL construction until openSocket runs, and keep
the logic centered around primeRelayAffinity and openSocket so the pre-flight
setup still works without minting the token too early.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx:
- Around line 89-93: The token provider in TerminalPane is being captured as the
current function instance, which can become stale when the workspace client
changes. In TerminalPane’s useRef/useWorkspaceClient setup (and the same
reconnect logic in the other affected spots), pass a stable wrapper that reads
getWsTokenRef.current at dial time instead of storing getWsToken directly. This
ensures reconnects always use the latest token provider even when the base URL
stays the same.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8da40048-fdb7-49fe-beec-8937e5cd0496

📥 Commits

Reviewing files that changed from the base of the PR and between 20ed70f and 2a35f66.

📒 Files selected for processing (3)
  • apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx

Comment on lines 433 to +434
if (needsPreFlight) {
void primeRelayAffinity(actualUrl).then(openSocket);
void primeRelayAffinity(buildDialUrl()).then(openSocket);

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard token minting on the pre-flight path.

buildDialUrl() is protected inside openSocket, but Line 434 evaluates it before primeRelayAffinity(...). If _getToken throws, /hosts/ connections can remain stuck in "connecting" without reaching the logged reconnect path.

Proposed fix
 if (needsPreFlight) {
-	void primeRelayAffinity(buildDialUrl()).then(openSocket);
+	void Promise.resolve()
+		.then(() => primeRelayAffinity(buildDialUrl()))
+		.catch(() => undefined)
+		.then(openSocket);
 } else {
 	openSocket();
 }
📝 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
if (needsPreFlight) {
void primeRelayAffinity(actualUrl).then(openSocket);
void primeRelayAffinity(buildDialUrl()).then(openSocket);
if (needsPreFlight) {
void Promise.resolve()
.then(() => primeRelayAffinity(buildDialUrl()))
.catch(() => undefined)
.then(openSocket);
} else {
openSocket();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts` around lines
433 - 434, Guard token minting on the pre-flight relay path by moving the
buildDialUrl() call out of the eager primeRelayAffinity(...) argument in
terminal-ws-transport.ts; if _getToken can throw, it should be handled through
the existing openSocket flow so /hosts/ connections still reach the
reconnect/error path instead of staying stuck in "connecting". Update the
needsPreFlight branch to defer URL construction until openSocket runs, and keep
the logic centered around primeRelayAffinity and openSocket so the pre-flight
setup still works without minting the token too early.

Comment on lines +89 to +93
// The transport re-mints the token on every (re)dial, so the JWT stays fresh
// across reconnects instead of replaying the one baked into websocketUrl.
const { getWsToken } = useWorkspaceClient();
const getWsTokenRef = useRef(getWsToken);
getWsTokenRef.current = getWsToken;

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Pass a stable token-provider wrapper, not the current function value.

getWsTokenRef.current is evaluated when the effect runs, so the transport stores that function instance. If the workspace client/provider changes without a base URL change, reconnect dials can keep using the stale token provider. Store a stable wrapper that dereferences the ref at dial time.

Proposed fix
 const { getWsToken } = useWorkspaceClient();
 const getWsTokenRef = useRef(getWsToken);
 getWsTokenRef.current = getWsToken;
+const getFreshWsToken = useCallback(() => getWsTokenRef.current(), []);
 terminalRuntimeRegistry.connect(
 	terminalId,
 	websocketUrlRef.current,
 	terminalInstanceId,
-	getWsTokenRef.current,
+	getFreshWsToken,
 );
 terminalRuntimeRegistry.reconnect(
 	terminalId,
 	websocketUrlRef.current,
 	terminalInstanceId,
-	getWsTokenRef.current,
+	getFreshWsToken,
 );

Also applies to: 151-155, 210-214

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx
around lines 89 - 93, The token provider in TerminalPane is being captured as
the current function instance, which can become stale when the workspace client
changes. In TerminalPane’s useRef/useWorkspaceClient setup (and the same
reconnect logic in the other affected spots), pass a stable wrapper that reads
getWsTokenRef.current at dial time instead of storing getWsToken directly. This
ensures reconnects always use the latest token provider even when the base URL
stays the same.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx:155">
P1: `getWsTokenRef.current` is evaluated at effect-run time, so the transport captures that specific function instance. If `useWorkspaceClient()` later returns a new `getWsToken` (e.g., after a workspace-client rebuild) without a base URL change, the effect won't re-run and reconnects will keep calling the stale provider — reintroducing the token-staleness issue this PR addresses.

Pass a stable wrapper that dereferences the ref at dial time:

```ts
const getFreshWsToken = useCallback(() => getWsTokenRef.current(), []);

Then pass getFreshWsToken to both connect() and reconnect().

P2: If `_getToken` throws synchronously inside `buildDialUrl()`, the exception propagates out of this expression unhandled — the transport remains stuck in `"connecting"` state with no error recovery or reconnect attempt. Consider wrapping `buildDialUrl()` in a `.then()` chain or a try/catch so that a token-minting failure still falls through to the reconnect/error path:
if (needsPreFlight) {
	void Promise.resolve()
		.then(() => primeRelayAffinity(buildDialUrl()))
		.catch(() => undefined)
		.then(openSocket);
} else {
	openSocket();
}
```</violation>
</file>
Architecture diagram
sequenceDiagram
    participant TP as TerminalPane (React)
    participant RR as TerminalRuntimeRegistry
    participant TR as TerminalWsTransport
    participant WSC as useWorkspaceClient
    participant Relay as Relay Edge

    Note over TP,Relay: NEW: Fresh JWT per (re)dial flow

    TP->>WSC: getWsToken()
    WSC-->>TP: token provider

    Note over TP: stores getWsTokenRef<br/>for stable reference

    TP->>RR: connect(terminalId, wsUrl, instanceId, getWsToken)
    RR->>TR: connect(transport, terminal, wsUrl, getToken)
    
    Note over TR: stripUrlToken(wsUrl)<br/>→ baseUrl (token removed)
    Note over TR: transport.currentUrl = baseUrl<br/>transport._getToken = getToken

    alt First connect (needs pre-flight for /hosts/ paths)
        TR->>TR: buildDialUrl() → mint fresh token
        TR->>Relay: primeRelayAffinity(buildDialUrl())
        Relay-->>TR: affinity established
        TR->>TR: buildDialUrl() → mint another fresh token
        TR->>Relay: NEW: WebSocket(buildDialUrl())
        Note over TR: token from _getToken(),<br/>not from cached URL
        Relay-->>TR: WebSocket open
    else Direct connect (non-/hosts/ path)
        TR->>Relay: NEW: WebSocket(buildDialUrl())
        Relay-->>TR: WebSocket open
    end
    TR-->>RR: connected
    RR-->>TP: ok

    Note over TP,Relay: RECONNECT (e.g. sleep/wake)

    TP->>RR: reconnect(terminalId, wsUrl, instanceId, getWsToken)
    Note over RR: stripUrlToken(wsUrl) == currentUrl?<br/>→ Yes, only token changed
    RR->>TR: connect(transport, terminal, wsUrl, getToken)
    
    Note over TR: close old socket,<br/>cancel reconnect timer
    
    TR->>TR: buildDialUrl() → mint fresh token
    Note over TR: CHANGED: token NOT from baked URL<br/>→ fresh from _getToken()
    alt Needs pre-flight
        TR->>Relay: primeRelayAffinity(buildDialUrl())
        Relay-->>TR: affinity established
        TR->>Relay: WebSocket(buildDialUrl()) with fresh token
    else Direct
        TR->>Relay: WebSocket(buildDialUrl()) with fresh token
    end

    alt Token valid (freshly minted)
        Relay-->>TR: WebSocket open (200)
    else Token expired / invalid
        Relay-->>TR: 401 reject
        Note over TR: old behavior: replayed baked token → 401<br/>NEW: fresh token → usually 200
    end

    Note over TR: on reconnect: transport._hasReceivedBytes<br/>→ append replay=0 query param<br/>to baseUrl before dial
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

terminalId,
websocketUrlRef.current,
terminalInstanceId,
getWsTokenRef.current,

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.

P1: getWsTokenRef.current is evaluated at effect-run time, so the transport captures that specific function instance. If useWorkspaceClient() later returns a new getWsToken (e.g., after a workspace-client rebuild) without a base URL change, the effect won't re-run and reconnects will keep calling the stale provider — reintroducing the token-staleness issue this PR addresses.

Pass a stable wrapper that dereferences the ref at dial time:

const getFreshWsToken = useCallback(() => getWsTokenRef.current(), []);

Then pass getFreshWsToken to both connect() and reconnect().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx, line 155:

<comment>`getWsTokenRef.current` is evaluated at effect-run time, so the transport captures that specific function instance. If `useWorkspaceClient()` later returns a new `getWsToken` (e.g., after a workspace-client rebuild) without a base URL change, the effect won't re-run and reconnects will keep calling the stale provider — reintroducing the token-staleness issue this PR addresses.

Pass a stable wrapper that dereferences the ref at dial time:

```ts
const getFreshWsToken = useCallback(() => getWsTokenRef.current(), []);

Then pass getFreshWsToken to both connect() and reconnect().

@@ -147,6 +152,7 @@ export function TerminalPane({ terminalId, websocketUrlRef.current, terminalInstanceId, + getWsTokenRef.current, );

</file context>


</details>

}
if (needsPreFlight) {
void primeRelayAffinity(actualUrl).then(openSocket);
void primeRelayAffinity(buildDialUrl()).then(openSocket);

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.

P2: If _getToken throws synchronously inside buildDialUrl(), the exception propagates out of this expression unhandled — the transport remains stuck in "connecting" state with no error recovery or reconnect attempt. Consider wrapping buildDialUrl() in a .then() chain or a try/catch so that a token-minting failure still falls through to the reconnect/error path:

if (needsPreFlight) {
	void Promise.resolve()
		.then(() => primeRelayAffinity(buildDialUrl()))
		.catch(() => undefined)
		.then(openSocket);
} else {
	openSocket();
}
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts, line 434:

<comment>If `_getToken` throws synchronously inside `buildDialUrl()`, the exception propagates out of this expression unhandled — the transport remains stuck in `"connecting"` state with no error recovery or reconnect attempt. Consider wrapping `buildDialUrl()` in a `.then()` chain or a try/catch so that a token-minting failure still falls through to the reconnect/error path:

```ts
if (needsPreFlight) {
	void Promise.resolve()
		.then(() => primeRelayAffinity(buildDialUrl()))
		.catch(() => undefined)
		.then(openSocket);
} else {
	openSocket();
}
```</comment>

<file context>
@@ -397,12 +426,12 @@ export function connect(
 	}
 	if (needsPreFlight) {
-		void primeRelayAffinity(actualUrl).then(openSocket);
+		void primeRelayAffinity(buildDialUrl()).then(openSocket);
 	} else {
 		openSocket();
</file context>
Suggested change
void primeRelayAffinity(buildDialUrl()).then(openSocket);
void Promise.resolve()
.then(() => primeRelayAffinity(buildDialUrl()))
.catch(() => undefined)
.then(openSocket);

This branch has not been deployed

No deployments
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.

1 participant