Skip to content

Implement server auth bootstrap and pairing flow#1768

Open
juliusmarminge wants to merge 11 commits intot3code/remote-host-modelfrom
t3code/remote-auth-pairing
Open

Implement server auth bootstrap and pairing flow#1768
juliusmarminge wants to merge 11 commits intot3code/remote-host-modelfrom
t3code/remote-auth-pairing

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 6, 2026

Summary

  • add a unified server auth model with bootstrap credentials, signed sessions, and HTTP/WS protection
  • remove the legacy static auth token path and add a dedicated /pair route for browser pairing
  • move auth gating into router beforeLoad, add a first-paint loading shell, and document the auth architecture

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • cd apps/web && bun run test src/authBootstrap.test.ts
  • cd apps/server && bun run test src/server.test.ts -t "rejects reusing the same bootstrap credential after it has been exchanged"

Note

High Risk
Introduces a new server-wide auth layer (bootstrap exchange + signed sessions) and enforces it across HTTP routes and WebSocket upgrades, replacing the legacy ?token= flow; mismatches between clients, cookies, and auth gating can break connectivity if any path is missed.

Overview
Adds a server-wide authentication system based on one-time bootstrap credentials exchanged for signed session tokens (cookie/bearer), with new endpoints GET /api/auth/session and POST /api/auth/bootstrap and a persistent secret store under stateDir/secrets.

Enforces auth on previously open surfaces (WebSocket /ws upgrade plus HTTP routes like attachments, project favicon, and OTLP trace proxy), removes the legacy authToken config/env/CLI flag in favor of a desktopBootstrapToken, and updates desktop startup to pass bootstrap data and wait for backend HTTP readiness.

Updates the web app to gate routes via router beforeLoad, adds a dedicated /pair flow that can auto-consume ?token= or accept manual entry, introduces a first-paint splash shell + theme preflight, and adjusts URL helpers/tests to use resolveServerHttpUrl for same-origin HTTP in local dev while keeping WS endpoints unchanged.

Reviewed by Cursor Bugbot for commit 5a21931. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Implement server auth bootstrap and pairing flow with session-based authentication

  • Introduces a full authentication system: the server exposes /api/auth/session and /api/auth/bootstrap endpoints, issues signed session tokens, and manages one-time bootstrap credentials with atomic single-use semantics
  • Adds ServerAuth, SessionCredentialService, BootstrapCredentialService, ServerSecretStore, and ServerAuthPolicy Effect services on the server, with secrets persisted to disk under a new secretsDir path
  • WebSocket upgrades and HTTP routes (/attachments/*, /api/project-favicon, /api/observability/v1/traces) now require session authentication; the previous static authToken / --auth-token mechanism is removed
  • Adds a /pair route in the web app that auto-submits a one-time token from the URL query string or prompts for manual credential entry; unauthenticated access to /_chat and /settings redirects there
  • Desktop startup now binds the backend to a fixed loopback host, polls /api/auth/session for readiness, and seeds a desktopBootstrapToken instead of the prior auth token; ELECTRON_RENDERER_PORT and T3CODE_AUTH_TOKEN env vars are removed
  • Risk: breaking change — authToken/T3CODE_AUTH_TOKEN/--auth-token are fully removed; existing integrations relying on query-parameter token auth or the old env var will no longer authenticate

Macroscope summarized 5a21931.

Co-authored-by: codex <codex@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 73c11734-3b57-4039-8e4f-7d6d4304612d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/remote-auth-pairing

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

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 6, 2026
Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Pairing URL points to root, token lost during redirect
    • Added url.pathname = "/pair" in issueStartupPairingUrl so the generated URL navigates directly to /pair?token=..., avoiding the redirect that strips the query parameter.
  • ✅ Fixed: Secret store set swallows write errors silently
    • Changed Effect.map(() => new SecretStoreError(...)) to Effect.flatMap(() => Effect.fail(new SecretStoreError(...))) so write failures properly propagate as Effect errors instead of being swallowed as success values.
  • ✅ Fixed: Bootstrap credential consume has TOCTOU race condition
    • Replaced the non-atomic Ref.get + Ref.update sequence with a single Ref.modify call that atomically reads the grant, validates it, and updates the map in one operation.
  • ✅ Fixed: Duplicate one-time tokens issued during startup
    • In non-desktop mode, resolveStartupBrowserTarget is now called once and the resulting URL is reused for both logging and browser opening, avoiding issuing two separate one-time tokens.

Create PR

Or push these changes by commenting:

@cursor push f99e419f7c
Preview (f99e419f7c)
diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
--- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts
+++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
@@ -49,47 +49,60 @@
       return credential;
     });
 
+  type ConsumeResult =
+    | { readonly _tag: "error"; readonly error: BootstrapCredentialError }
+    | { readonly _tag: "ok"; readonly grant: BootstrapGrant };
+
   const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
     Effect.gen(function* () {
-      const current = yield* Ref.get(grantsRef);
-      const grant = current.get(credential);
-      if (!grant) {
-        return yield* new BootstrapCredentialError({
-          message: "Unknown bootstrap credential.",
-        });
-      }
+      const now = yield* DateTime.now;
+      const result = yield* Ref.modify(
+        grantsRef,
+        (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
+          const grant = current.get(credential);
+          if (!grant) {
+            return [
+              {
+                _tag: "error",
+                error: new BootstrapCredentialError({ message: "Unknown bootstrap credential." }),
+              },
+              current,
+            ];
+          }
 
-      if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, grant.expiresAt)) {
-        yield* Ref.update(grantsRef, (state) => {
-          const next = new Map(state);
-          next.delete(credential);
-          return next;
-        });
-        return yield* new BootstrapCredentialError({
-          message: "Bootstrap credential expired.",
-        });
-      }
-
-      const remainingUses = grant.remainingUses;
-      if (typeof remainingUses === "number") {
-        yield* Ref.update(grantsRef, (state) => {
-          const next = new Map(state);
-          if (remainingUses <= 1) {
+          if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
+            const next = new Map(current);
             next.delete(credential);
-          } else {
-            next.set(credential, {
-              ...grant,
-              remainingUses: remainingUses - 1,
-            });
+            return [
+              {
+                _tag: "error",
+                error: new BootstrapCredentialError({ message: "Bootstrap credential expired." }),
+              },
+              next,
+            ];
           }
-          return next;
-        });
+
+          const next = new Map(current);
+          const remainingUses = grant.remainingUses;
+          if (typeof remainingUses === "number") {
+            if (remainingUses <= 1) {
+              next.delete(credential);
+            } else {
+              next.set(credential, { ...grant, remainingUses: remainingUses - 1 });
+            }
+          }
+
+          return [
+            { _tag: "ok", grant: { method: grant.method, expiresAt: grant.expiresAt } },
+            next,
+          ];
+        },
+      );
+
+      if (result._tag === "error") {
+        return yield* result.error;
       }
-
-      return {
-        method: grant.method,
-        expiresAt: grant.expiresAt,
-      } satisfies BootstrapGrant;
+      return result.grant;
     });
 
   return {

diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts
--- a/apps/server/src/auth/Layers/ServerAuth.ts
+++ b/apps/server/src/auth/Layers/ServerAuth.ts
@@ -124,6 +124,7 @@
     bootstrapCredentials.issueOneTimeToken().pipe(
       Effect.map((credential) => {
         const url = new URL(baseUrl);
+        url.pathname = "/pair";
         url.searchParams.set("token", credential);
         return url.toString();
       }),

diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts
--- a/apps/server/src/auth/Layers/ServerSecretStore.ts
+++ b/apps/server/src/auth/Layers/ServerSecretStore.ts
@@ -47,8 +47,10 @@
       Effect.catch((cause) =>
         fileSystem.remove(tempPath).pipe(
           Effect.orElseSucceed(() => undefined),
-          Effect.map(
-            () => new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
+          Effect.flatMap(() =>
+            Effect.fail(
+              new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
+            ),
           ),
         ),
       ),

diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -388,8 +388,22 @@
         yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", {
           pairingUrl,
         });
+        if (!serverConfig.noBrowser) {
+          const { openBrowser } = yield* Open;
+          yield* runStartupPhase(
+            "browser.open",
+            openBrowser(pairingUrl).pipe(
+              Effect.catch(() =>
+                Effect.logInfo("browser auto-open unavailable", {
+                  hint: `Open ${pairingUrl} in your browser.`,
+                }),
+              ),
+            ),
+          );
+        }
+      } else {
+        yield* runStartupPhase("browser.open", maybeOpenBrowser);
       }
-      yield* runStartupPhase("browser.open", maybeOpenBrowser);
       yield* Effect.logDebug("startup phase: complete");
     }),
   );

You can send follow-ups to the cloud agent here.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 6, 2026

Approvability

Verdict: Needs human review

This PR introduces new authentication infrastructure including bootstrap credentials, session management, and pairing flows - explicitly security-sensitive code that requires human review. Additionally, there are multiple unresolved review comments identifying security concerns including session tokens being exposed in response bodies alongside httpOnly cookies, and missing secure flags on cookies for remote environments.

You can customize Macroscope's approvability policy. Learn more.

Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Auth bootstrap fetch uses unsupported ws:// protocol URL
    • Added a wsUrlToHttpUrl() helper that converts ws:/wss: protocols to http:/https: and applied it at all three call sites where resolvePrimaryEnvironmentBootstrapUrl() is passed to fetch().

Create PR

Or push these changes by commenting:

@cursor push 59ffed3254
Preview (59ffed3254)
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts
--- a/apps/web/src/authBootstrap.test.ts
+++ b/apps/web/src/authBootstrap.test.ts
@@ -86,10 +86,10 @@
 
     expect(fetchMock).toHaveBeenCalledTimes(2);
     expect(fetchMock.mock.calls[0]?.[0]).toEqual(
-      new URL("/api/auth/session", "ws://localhost:3773/"),
+      new URL("/api/auth/session", "http://localhost:3773/"),
     );
     expect(fetchMock.mock.calls[1]?.[0]).toEqual(
-      new URL("/api/auth/bootstrap", "ws://localhost:3773/"),
+      new URL("/api/auth/bootstrap", "http://localhost:3773/"),
     );
   });
 

diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -2,6 +2,13 @@
 
 import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap";
 
+function wsUrlToHttpUrl(url: string): string {
+  const parsed = new URL(url);
+  if (parsed.protocol === "ws:") parsed.protocol = "http:";
+  else if (parsed.protocol === "wss:") parsed.protocol = "https:";
+  return parsed.href;
+}
+
 export type ServerAuthGateState =
   | { status: "authenticated" }
   | {
@@ -80,7 +87,7 @@
 }
 
 async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
-  const baseUrl = resolvePrimaryEnvironmentBootstrapUrl();
+  const baseUrl = wsUrlToHttpUrl(resolvePrimaryEnvironmentBootstrapUrl());
   const bootstrapCredential = getBootstrapCredential();
   const currentSession = await fetchSessionState(baseUrl);
   if (currentSession.authenticated) {
@@ -112,7 +119,10 @@
     throw new Error("Enter a pairing token to continue.");
   }
 
-  await exchangeBootstrapCredential(resolvePrimaryEnvironmentBootstrapUrl(), trimmedCredential);
+  await exchangeBootstrapCredential(
+    wsUrlToHttpUrl(resolvePrimaryEnvironmentBootstrapUrl()),
+    trimmedCredential,
+  );
   stripPairingTokenFromUrl();
 }

You can send follow-ups to the cloud agent here.

- Decrement remaining uses atomically on consume
- Clean up temp files without masking secret persistence errors
- Convert ws/wss environment URLs to fetchable http/https bases
- Cover remote pairing auth bootstrap and URL resolution with tests
- Treat missing-file removals as success
- Wrap other remove failures in SecretStoreError
- Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Cached auth state not invalidated after successful pairing
    • Added bootstrapPromise = null after the successful credential exchange in submitServerAuthCredential, so subsequent calls to resolveInitialServerAuthGateState re-evaluate the auth state instead of returning the stale cached requires-auth promise.

Create PR

Or push these changes by commenting:

@cursor push 264a9be49a
Preview (264a9be49a)
diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -123,6 +123,7 @@
 
   await exchangeBootstrapCredential(resolvePrimaryEnvironmentHttpBaseUrl(), trimmedCredential);
   stripPairingTokenFromUrl();
+  bootstrapPromise = null;
 }
 
 export function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {

You can send follow-ups to the cloud agent here.

juliusmarminge and others added 3 commits April 5, 2026 21:18
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>

if (!response.ok) {
const message = await response.text();
throw new Error(message || `Failed to bootstrap auth session (${response.status}).`);
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.

Error response displayed as raw JSON string

Medium Severity

When the bootstrap credential exchange fails, exchangeBootstrapCredential reads the error response body with response.text(), yielding the raw JSON string {"error":"Invalid bootstrap credential."}. This string is then surfaced as the user-facing error message on the pairing screen via errorMessageFromUnknown. The server's toUnauthorizedResponse sends a JSON body, so the client needs to parse it and extract the .error field instead of using the raw text.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2e4818. Configure here.

} catch {
return baseUrl;
}
}
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.

HTTP base URL retains WebSocket path component

Low Severity

getKnownEnvironmentHttpBaseUrl converts the protocol from ws:/wss: to http:/https: but preserves the full path (e.g., ws://host:port/wshttp://host:port/ws). Current callers use new URL("/absolute/path", baseUrl) which replaces the path entirely, making this safe today. However, the function name implies a "base URL" suitable for constructing endpoints, and any future caller using string concatenation (like ${baseUrl}/api/...) would produce incorrect URLs like http://host:port/ws/api/....

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2e4818. Configure here.

- Clarify auth policy, bootstrap, and session method meanings
- Describe the server auth descriptor used by clients
- Add guidance for pairing and steady-state auth flows
- Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for 2 of the 3 issues found in the latest run.

  • ✅ Fixed: Token split allows extra segments to pass verification
    • Added a check that token.split(".") produces exactly 2 parts before destructuring, rejecting tokens with extra segments.
  • ✅ Fixed: Secret store getOrCreateRandom has TOCTOU race condition
    • Wrapped the read-then-write sequence in getOrCreateRandom with a Semaphore(1) mutex to ensure atomicity.

Create PR

Or push these changes by commenting:

@cursor push f69311f5db
Preview (f69311f5db)
diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts
--- a/apps/server/src/auth/Layers/ServerSecretStore.ts
+++ b/apps/server/src/auth/Layers/ServerSecretStore.ts
@@ -1,6 +1,6 @@
 import * as Crypto from "node:crypto";
 
-import { Effect, FileSystem, Layer, Path } from "effect";
+import { Effect, FileSystem, Layer, Path, Semaphore } from "effect";
 import * as PlatformError from "effect/PlatformError";
 
 import { ServerConfig } from "../../config.ts";
@@ -60,6 +60,8 @@
     );
   };
 
+  const mutex = yield* Semaphore.make(1);
+
   const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
     get(name).pipe(
       Effect.flatMap((existing) => {
@@ -70,6 +72,7 @@
         const generated = Crypto.randomBytes(bytes);
         return set(name, generated).pipe(Effect.as(Uint8Array.from(generated)));
       }),
+      mutex.withPermits(1),
     );
 
   const remove: ServerSecretStoreShape["remove"] = (name) =>

diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts
--- a/apps/server/src/auth/Layers/SessionCredentialService.ts
+++ b/apps/server/src/auth/Layers/SessionCredentialService.ts
@@ -56,7 +56,13 @@
   });
 
   const verify: SessionCredentialServiceShape["verify"] = Effect.fn("verify")(function* (token) {
-    const [encodedPayload, signature] = token.split(".");
+    const parts = token.split(".");
+    if (parts.length !== 2) {
+      return yield* new SessionCredentialError({
+        message: "Malformed session token.",
+      });
+    }
+    const [encodedPayload, signature] = parts;
     if (!encodedPayload || !signature) {
       return yield* new SessionCredentialError({
         message: "Malformed session token.",

You can send follow-ups to the cloud agent here.

return yield* new SessionCredentialError({
message: "Malformed session token.",
});
}
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.

Token split allows extra segments to pass verification

Low Severity

The verify function uses token.split(".") without limiting the result to exactly two segments. A token like payload.validSig.arbitraryData would still pass verification because only the first two segments are destructured and the rest are silently ignored. The HMAC is verified against just the first segment, so extra segments have no effect on the signature check. This violates strict token format validation — the function accepts malformed tokens without rejecting them.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be42c17. Configure here.

const generated = Crypto.randomBytes(bytes);
return set(name, generated).pipe(Effect.as(Uint8Array.from(generated)));
}),
);
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.

Secret store getOrCreateRandom has TOCTOU race condition

Low Severity

The getOrCreateRandom function performs a non-atomic read-then-write sequence via separate get and set calls. If two fibers call this concurrently, both could observe null from get, both generate different random secrets, and the last writer wins — leaving the first fiber holding a secret that doesn't match what's persisted on disk. This could cause session token verification failures if the signing key is overwritten mid-startup.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be42c17. Configure here.

sessions.issue({
method: "browser-session-cookie",
subject: grant.method,
}),
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.

Bootstrap exchange always hardcodes browser-session-cookie method

Low Severity

In exchangeBootstrapCredential, the session is always issued with method: "browser-session-cookie" regardless of how the client will actually use the credential. When the session token is returned in the response body and used as a bearer token (e.g., for WebSocket ?token= query param auth), the session's method claim doesn't reflect the actual usage. The ServerAuthDescriptor advertises both "browser-session-cookie" and "bearer-session-token" as supported session methods, but only the former is ever issued.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be42c17. Configure here.

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.

Bugbot Autofix determined this is a false positive.

The bootstrap exchange sets the session cookie via Set-Cookie header, making browser-session-cookie the correct method; the token in the response body is a convenience copy, and at exchange time the server cannot know which transport the client will ultimately use.

You can send follow-ups to the cloud agent here.

- route auth and observability requests through the active web origin
- simplify desktop startup for pairing mode and loopback backend binding
- improve the boot shell while the app waits for the initial config snapshot
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.

🟢 Low

function startApp() {

VITE_DEV_SERVER_URL is trimmed and validated by the parent on lines 8-17, but the child process receives the untrimmed value directly from process.env via childEnv. When the original environment variable contains whitespace, the parent accepts the URL after trimming while the child receives the raw value, causing URL parsing failures in the Electron app. Consider passing the validated devServerUrl to the child explicitly, or ensuring childEnv uses the trimmed value.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/scripts/dev-electron.mjs around line 65:

`VITE_DEV_SERVER_URL` is trimmed and validated by the parent on lines 8-17, but the child process receives the untrimmed value directly from `process.env` via `childEnv`. When the original environment variable contains whitespace, the parent accepts the URL after trimming while the child receives the raw value, causing URL parsing failures in the Electron app. Consider passing the validated `devServerUrl` to the child explicitly, or ensuring `childEnv` uses the trimmed value.

Evidence trail:
apps/desktop/scripts/dev-electron.mjs lines 8, 37, and 65-73 at REVIEWED_COMMIT:
- Line 8: `const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();` (trimmed for validation)
- Line 37: `const childEnv = { ...process.env };` (spreads original untrimmed env)
- Lines 65-73: `spawn(..., { env: childEnv, ... })` (child receives untrimmed value)

Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 7 total unresolved issues (including 5 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Session token exposed in response body alongside HttpOnly cookie
    • Stripped sessionToken from the bootstrap JSON response body by destructuring it out before serialization, so the token is only transmitted via the httpOnly cookie.
  • ✅ Fixed: Session cookie missing secure flag for non-loopback environments
    • Added conditional secure: descriptor.policy === "remote-reachable" to the cookie options so the flag is set when the server is configured for remote access.

Create PR

Or push these changes by commenting:

@cursor push 21a3606f32
Preview (21a3606f32)
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -40,12 +40,14 @@
     );
     const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential);
 
-    return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe(
+    const { sessionToken: _token, ...responseBody } = result;
+    return yield* HttpServerResponse.jsonUnsafe(responseBody, { status: 200 }).pipe(
       HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, {
         expires: DateTime.toDate(result.expiresAt),
         httpOnly: true,
         path: "/",
         sameSite: "lax",
+        secure: descriptor.policy === "remote-reachable",
       }),
     );
   }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))),

diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -492,6 +492,12 @@
     return `http://127.0.0.1:${address.port}${pathname}`;
   });
 
+function parseSessionTokenFromSetCookie(setCookie: string | null): string | null {
+  if (!setCookie) return null;
+  const match = /t3_session=([^;]+)/.exec(setCookie);
+  return match?.[1] ?? null;
+}
+
 const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) =>
   Effect.gen(function* () {
     const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap");
@@ -509,13 +515,15 @@
     const body = (yield* Effect.promise(() => response.json())) as {
       readonly authenticated: boolean;
       readonly sessionMethod: string;
-      readonly sessionToken: string;
       readonly expiresAt: string;
     };
+    const cookie = response.headers.get("set-cookie");
+    const sessionToken = parseSessionTokenFromSetCookie(cookie);
     return {
       response,
       body,
-      cookie: response.headers.get("set-cookie"),
+      cookie,
+      sessionToken,
     };
   });
 
@@ -525,18 +533,18 @@
       return cachedDefaultSessionToken;
     }
 
-    const { response, body } = yield* bootstrapBrowserSession(credential);
-    if (!response.ok) {
+    const { response, sessionToken } = yield* bootstrapBrowserSession(credential);
+    if (!response.ok || !sessionToken) {
       return yield* Effect.fail(
         new Error(`Expected bootstrap session response to succeed, got ${response.status}`),
       );
     }
 
     if (credential === defaultDesktopBootstrapToken) {
-      cachedDefaultSessionToken = body.sessionToken;
+      cachedDefaultSessionToken = sessionToken;
     }
 
-    return body.sessionToken;
+    return sessionToken;
   });
 
 const getWsServerUrl = (
@@ -720,13 +728,15 @@
     Effect.gen(function* () {
       yield* buildAppUnderTest();
 
-      const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession();
+      const { response: bootstrapResponse, sessionToken: bootstrapSessionToken } =
+        yield* bootstrapBrowserSession();
 
       assert.equal(bootstrapResponse.status, 200);
+      assert(bootstrapSessionToken, "Expected session token in Set-Cookie header");
 
       const wsUrl = appendSessionTokenToUrl(
         yield* getWsServerUrl("/ws", { authenticated: false }),
-        bootstrapBody.sessionToken,
+        bootstrapSessionToken,
       );
       const response = yield* Effect.scoped(
         withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),

diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts
--- a/apps/web/src/authBootstrap.test.ts
+++ b/apps/web/src/authBootstrap.test.ts
@@ -65,7 +65,6 @@
         jsonResponse({
           authenticated: true,
           sessionMethod: "browser-session-cookie",
-          sessionToken: "session-token",
           expiresAt: "2026-04-05T00:00:00.000Z",
         }),
       );
@@ -207,7 +206,6 @@
         jsonResponse({
           authenticated: true,
           sessionMethod: "browser-session-cookie",
-          sessionToken: "session-token",
           expiresAt: "2026-04-05T00:00:00.000Z",
         }),
       );

diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -1,4 +1,4 @@
-import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts";
+import type { AuthBootstrapInput, AuthSessionState } from "@t3tools/contracts";
 import { resolveServerHttpUrl } from "./lib/utils";
 
 export type ServerAuthGateState =
@@ -56,7 +56,7 @@
   return (await response.json()) as AuthSessionState;
 }
 
-async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> {
+async function exchangeBootstrapCredential(credential: string): Promise<void> {
   const payload: AuthBootstrapInput = { credential };
   const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), {
     body: JSON.stringify(payload),
@@ -71,8 +71,6 @@
     const message = await response.text();
     throw new Error(message || `Failed to bootstrap auth session (${response.status}).`);
   }
-
-  return (await response.json()) as AuthBootstrapResult;
 }
 
 async function bootstrapServerAuth(): Promise<ServerAuthGateState> {

diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts
--- a/apps/web/test/authHttpHandlers.ts
+++ b/apps/web/test/authHttpHandlers.ts
@@ -2,7 +2,6 @@
 import { HttpResponse, http } from "msw";
 
 const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";
-const TEST_SESSION_TOKEN = "browser-test-session-token";
 
 export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {
   return [
@@ -18,7 +17,6 @@
       HttpResponse.json({
         authenticated: true,
         sessionMethod: "browser-session-cookie",
-        sessionToken: TEST_SESSION_TOKEN,
         expiresAt: TEST_SESSION_EXPIRES_AT,
       }),
     ),

You can send follow-ups to the cloud agent here.

path: "/",
sameSite: "lax",
}),
);
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.

Session token exposed in response body alongside HttpOnly cookie

Medium Severity

The /api/auth/bootstrap response returns the raw sessionToken in the JSON body while simultaneously setting it as an httpOnly cookie. The httpOnly flag is meant to protect the session credential from JavaScript access (e.g., XSS), but including the same token in the response body completely negates that protection — any script on the page can read it from the fetch response. If the token is needed for non-cookie auth (bearer/WebSocket), it would be better to issue a separate bearer-only token for that purpose.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dca54c7. Configure here.

httpOnly: true,
path: "/",
sameSite: "lax",
}),
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.

Session cookie missing secure flag for non-loopback environments

Medium Severity

The session cookie set by the bootstrap endpoint omits the secure flag entirely. While this is fine for local development over HTTP, the auth model explicitly supports remote-reachable environments where TLS is expected. Without secure, the cookie could be sent over plaintext HTTP on a remote/tunneled connection, exposing the session token to network eavesdropping. The flag could be set conditionally based on the auth policy or the request protocol.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dca54c7. Configure here.

- Replace the verbose boot shell with a minimal splash screen
- Move pairing loading UI into the /pair route pending state
- Gate the main app on bootstrap completion instead of auth preload
Copy link
Copy Markdown
Contributor

@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.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

There are 10 total unresolved issues (including 7 from previous reviews).

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues. You can view the agent here.

Reviewed by Cursor Bugbot for commit 5a21931. Configure here.

}),
remainingUses: 1,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Desktop bootstrap token expires before backend ready

Medium Severity

The desktop bootstrap token is seeded with a 5-minute TTL (DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES) computed from DateTime.now at service construction time. However, the desktop main process generates backendBootstrapToken before spawning the backend, and the token isn't exchanged until the renderer window loads and the auth bootstrap completes. If backend startup, window creation, or waitForBackendHttpReady is slow (e.g. heavy machine, or the 10-second readiness timeout fires), the 5-minute window measured from server layer construction could expire before the renderer exchanges it. A longer TTL or one anchored to when the server is actually ready to accept connections would be safer.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a21931. Configure here.

return "Disconnected from T3 Server";
}

function buildReconnectTitle(_status: WsConnectionStatus): string {
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.

Unused parameter in buildReconnectTitle after refactoring

Low Severity

buildReconnectTitle previously had a conditional branch using the status parameter (checking status.nextRetryAt). That branch was removed, but the function still accepts _status as a parameter. The function now unconditionally returns a static string, making it an unnecessary wrapper that could be inlined or simplified.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a21931. Configure here.

repositoryIdentity: true,
},
};
let cachedDefaultSessionToken: string | null = null;
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.

Module-level shared mutable state across parallel test runs

Medium Severity

cachedDefaultSessionToken is a module-level mutable variable used to cache the session token across multiple test helpers. Since buildAppUnderTest resets it to null (line 296), but each test builds a new server instance with a fresh signing key, a stale cached token from a prior test could be used if getAuthenticatedSessionToken is called before buildAppUnderTest in a test, or if test ordering assumptions are violated. More critically, the one-time bootstrap token can only be consumed once per server instance, so caching across calls within the same test works, but the pattern is fragile.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a21931. Configure here.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant