Skip to content

Implement server auth bootstrap and pairing flow#1768

Merged
juliusmarminge merged 60 commits intomainfrom
t3code/remote-auth-pairing
Apr 9, 2026
Merged

Implement server auth bootstrap and pairing flow#1768
juliusmarminge merged 60 commits intomainfrom
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
Touches core authentication/session handling for both HTTP and WebSocket paths and changes desktop bootstrap/exposure behavior; mistakes could lock users out or unintentionally expose a server on the network.

Overview
Adds a server-wide auth layer built around one-time bootstrap credentials (desktop bootstrap + pairing tokens), persisted/signed session credentials (cookie + bearer), and WebSocket upgrade auth via a new ServerAuth service stack.

Introduces new auth HTTP APIs (/api/auth/session, /api/auth/bootstrap, /api/auth/bootstrap/bearer, /api/auth/ws-token, plus owner-only pairing/client management routes) and supporting Effect layers/services (ServerSecretStore, BootstrapCredentialService, SessionCredentialService, AuthControlPlane) with extensive tests.

Updates the desktop app’s startup/IPC to replace the legacy getWsUrl flow with getLocalEnvironmentBootstrap (HTTP+WS base URLs + bootstrap token), adds persisted server exposure mode (local-only vs network-accessible) with LAN endpoint discovery and relaunch-on-change behavior, and tightens dev tooling to require VITE_DEV_SERVER_URL.

Adds architecture documentation for remote environments (.docs/remote-architecture.md) and updates formatter/CI checks for the new preload bridge surface.

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

Note

Implement server auth bootstrap and pairing flow with session management and CLI

  • Introduces a full server authentication system: ServerAuth, BootstrapCredentialService, and SessionCredentialService backed by SQLite (migrations 020–022), supporting browser-session-cookie and bearer-session-token methods.
  • Adds HTTP auth endpoints (POST /api/auth/bootstrap, /api/auth/bootstrap/bearer, GET /api/auth/session, POST /api/auth/ws-token) and enforces authentication on WebSocket upgrades and selected routes.
  • Adds t3 auth CLI subcommands to create/list/revoke pairing links and issue/list/revoke bearer sessions, replacing the old --auth-token flag with desktopBootstrapToken.
  • Desktop app gains local-only vs network-accessible server exposure modes with IPC APIs (getServerExposureState, setServerExposureMode), persisted settings, and a new backend readiness gate polling /api/auth/session.
  • Web client adds a /pair pairing route, a /settings/connections page, and multi-environment support in the sidebar, chat view, and branch toolbar.
  • Risk: getWsUrl IPC and authToken/T3CODE_AUTH_TOKEN are removed; consumers of DesktopBridge, KnownEnvironment, and WsRpcClient APIs must migrate to the new interfaces.

Macroscope summarized 21c748b.

@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: 434bf413-7072-41a3-8858-74825b74bdc8

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

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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

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.

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.

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.

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.

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.

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

Autofix Details

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

  • ✅ Fixed: Desktop bootstrap token expires before backend ready
    • Increased DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES from 5 to 10 minutes to provide a safer margin for slow backend startups, window creation, and renderer bootstrap exchange.
  • ✅ Fixed: Unused parameter in buildReconnectTitle after refactoring
    • Removed the dead buildReconnectTitle function entirely and inlined the constant string "Disconnected from T3 Server" at the single call site.
  • ✅ Fixed: Module-level shared mutable state across parallel test runs
    • Replaced the bare cached token string with a generation-tagged object so that stale tokens from prior server builds are automatically invalidated when buildAppUnderTest increments the generation counter.

Create PR

Or push these changes by commenting:

@cursor push d5344ec03b
Preview (d5344ec03b)
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
@@ -22,7 +22,7 @@
       readonly grant: BootstrapGrant;
     };
 
-const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
+const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(10);
 
 export const makeBootstrapCredentialService = Effect.gen(function* () {
   const config = yield* ServerConfig;

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
@@ -108,7 +108,8 @@
     repositoryIdentity: true,
   },
 };
-let cachedDefaultSessionToken: string | null = null;
+let serverBuildGeneration = 0;
+let cachedDefaultSessionToken: { token: string; generation: number } | null = null;
 
 const makeDefaultOrchestrationReadModel = () => {
   const now = new Date().toISOString();
@@ -293,7 +294,7 @@
   };
 }) =>
   Effect.gen(function* () {
-    cachedDefaultSessionToken = null;
+    serverBuildGeneration += 1;
     const fileSystem = yield* FileSystem.FileSystem;
     const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" });
     const baseDir = options?.config?.baseDir ?? tempBaseDir;
@@ -521,8 +522,13 @@
 
 const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken) =>
   Effect.gen(function* () {
-    if (credential === defaultDesktopBootstrapToken && cachedDefaultSessionToken) {
-      return cachedDefaultSessionToken;
+    const currentGeneration = serverBuildGeneration;
+    if (
+      credential === defaultDesktopBootstrapToken &&
+      cachedDefaultSessionToken &&
+      cachedDefaultSessionToken.generation === currentGeneration
+    ) {
+      return cachedDefaultSessionToken.token;
     }
 
     const { response, body } = yield* bootstrapBrowserSession(credential);
@@ -533,7 +539,7 @@
     }
 
     if (credential === defaultDesktopBootstrapToken) {
-      cachedDefaultSessionToken = body.sessionToken;
+      cachedDefaultSessionToken = { token: body.sessionToken, generation: currentGeneration };
     }
 
     return body.sessionToken;

diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx
--- a/apps/web/src/components/WebSocketConnectionSurface.tsx
+++ b/apps/web/src/components/WebSocketConnectionSurface.tsx
@@ -54,10 +54,6 @@
   return "Retries exhausted trying to reconnect";
 }
 
-function buildReconnectTitle(_status: WsConnectionStatus): string {
-  return "Disconnected from T3 Server";
-}
-
 function describeRecoveredToast(
   previousDisconnectedAt: string | null,
   connectedAt: string | null,
@@ -270,7 +266,7 @@
                   ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
                   : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
               timeout: 0,
-              title: buildReconnectTitle(status),
+              title: "Disconnected from T3 Server",
               type: "loading" as const,
               data: {
                 hideCopyButton: true,

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

@juliusmarminge juliusmarminge force-pushed the t3code/remote-auth-pairing branch from 3b06cc9 to 3759e05 Compare April 6, 2026 17:18
@juliusmarminge juliusmarminge force-pushed the t3code/remote-host-model branch from 314c455 to 54f905c Compare April 6, 2026 20:51
@juliusmarminge juliusmarminge force-pushed the t3code/remote-auth-pairing branch from 3759e05 to 4caff6f Compare April 7, 2026 04:30
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.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Desktop bootstrap token consumed but still advertised via IPC
    • The IPC handler now clears backendBootstrapToken after the first read, so subsequent calls return undefined instead of the stale, already-consumed token.

Create PR

Or push these changes by commenting:

@cursor push eebbb23e4b
Preview (eebbb23e4b)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1239,10 +1239,14 @@
 
   ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
   ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
+    const token = backendBootstrapToken || undefined;
+    if (token) {
+      backendBootstrapToken = "";
+    }
     event.returnValue = {
       label: "Local environment",
       wsUrl: backendWsUrl || null,
-      bootstrapToken: backendBootstrapToken || undefined,
+      bootstrapToken: token,
     } as const;
   });

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

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 4 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Network-accessible preference permanently lost on transient network failure
    • Added a degraded flag to skip persisting the settings when the mode was downgraded from network-accessible to local-only due to no available LAN address, preserving the user's original preference.
  • ✅ Fixed: Loopback hostname check inconsistent with auth policy
    • Added normalizedHostname.startsWith("127.") to isLoopbackHostname in http.ts to align with the broader 127.x.x.x range already recognized by isLoopbackHost in ServerAuthPolicy.ts.

Create PR

Or push these changes by commenting:

@cursor push 3b7b4cb840
Preview (3b7b4cb840)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -220,11 +220,13 @@
     ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
   });
 
+  let degraded = false;
   if (mode === "network-accessible" && exposure.mode !== "network-accessible") {
     if (options?.rejectIfUnavailable) {
       throw new Error("No reachable network address is available for this desktop right now.");
     }
     mode = "local-only";
+    degraded = true;
   }
 
   desktopServerExposureMode = exposure.mode;
@@ -241,7 +243,7 @@
   backendEndpointUrl = exposure.endpointUrl;
   backendAdvertisedHost = exposure.advertisedHost;
 
-  if (options?.persist || exposure.mode !== mode) {
+  if (!degraded && (options?.persist || exposure.mode !== mode)) {
     writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
   }
 

diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -34,7 +34,7 @@
     .trim()
     .toLowerCase()
     .replace(/^\[(.*)\]$/, "$1");
-  return LOOPBACK_HOSTNAMES.has(normalizedHostname);
+  return LOOPBACK_HOSTNAMES.has(normalizedHostname) || normalizedHostname.startsWith("127.");
 }
 
 export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {

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

@juliusmarminge juliusmarminge force-pushed the t3code/remote-auth-pairing branch from 9f966e4 to dc69564 Compare April 7, 2026 23:31
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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Unused authSessionRouteLayer export is dead code
    • Removed the unused authSessionRouteLayer export which was dead code, since only authSessionCorsRouteLayer (with CORS support) is actually imported and used in server.ts.

Create PR

Or push these changes by commenting:

@cursor push b263b7fc96
Preview (b263b7fc96)
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
@@ -28,17 +28,6 @@
     );
   });
 
-export const authSessionRouteLayer = HttpRouter.add(
-  "GET",
-  "/api/auth/session",
-  Effect.gen(function* () {
-    const request = yield* HttpServerRequest.HttpServerRequest;
-    const serverAuth = yield* ServerAuth;
-    const session = yield* serverAuth.getSessionState(request);
-    return HttpServerResponse.jsonUnsafe(session, { status: 200 });
-  }),
-);
-
 const REMOTE_AUTH_ALLOW_METHODS = "GET, POST, OPTIONS";
 const REMOTE_AUTH_ALLOW_HEADERS = "authorization, content-type";

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

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.

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

Autofix Details

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

  • ✅ Fixed: Fallback log message never emitted due to mutated state
    • Captured the original serverExposureMode into a local variable before calling applyDesktopServerExposureMode, so the fallback check compares against the pre-mutation value.

Create PR

Or push these changes by commenting:

@cursor push 552d1cfcbe
Preview (552d1cfcbe)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1667,18 +1667,16 @@
       `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
     );
   }
-  const serverExposureState = await applyDesktopServerExposureMode(
-    desktopSettings.serverExposureMode,
-    {
-      persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
-    },
-  );
+  const requestedExposureMode = desktopSettings.serverExposureMode;
+  const serverExposureState = await applyDesktopServerExposureMode(requestedExposureMode, {
+    persist: requestedExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
+  });
   writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
   if (serverExposureState.endpointUrl) {
     writeDesktopLogHeader(
       `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
     );
-  } else if (desktopSettings.serverExposureMode === "network-accessible") {
+  } else if (requestedExposureMode === "network-accessible") {
     writeDesktopLogHeader(
       "bootstrap fell back to local-only because no advertised network host was available",
     );

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

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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Dev redirect skips non-loopback LAN requests to dev server
    • Removed the isLoopbackHostname guard from the dev redirect condition so all requests are redirected to the Vite dev server when devUrl is configured, regardless of the incoming hostname.

Create PR

Or push these changes by commenting:

@cursor push b05c9dd39a
Preview (b05c9dd39a)
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -219,7 +219,7 @@
     }
 
     const config = yield* ServerConfig;
-    if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
+    if (config.devUrl) {
       return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
         status: 302,
       });

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

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.

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

Autofix Details

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

  • ✅ Fixed: Loopback local URLs unreachable when binding specific host
    • Changed the specific-host branch in resolveDesktopServerExposure to compute localHttpUrl and localWsUrl using selectedHost instead of the hardcoded 127.0.0.1 loopback address, so the desktop app connects to the address the server actually binds to.

Create PR

Or push these changes by commenting:

@cursor push 94810f1dd5
Preview (94810f1dd5)
diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts
--- a/apps/desktop/src/serverExposure.ts
+++ b/apps/desktop/src/serverExposure.ts
@@ -130,8 +130,8 @@
   return {
     mode: input.mode,
     bindHost: selectedHost,
-    localHttpUrl,
-    localWsUrl,
+    localHttpUrl: `http://${selectedHost}:${input.port}`,
+    localWsUrl: `ws://${selectedHost}:${input.port}`,
     endpointUrl: `http://${selectedHost}:${input.port}`,
     advertisedHost: selectedHost,
     availableHosts,

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

- Create secrets with exclusive open and retry after AlreadyExists
- Treat expired bootstrap tokens as hard failures
- Improve CLI and keybinding validation messages
- Persist the user’s requested server exposure mode even when a safer mode is applied temporarily
- Route auth bootstrap cookies through the session credential service
- Tighten secret-store read error handling for concurrent startup
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 3 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Unused appliedMode parameter creates misleading API
    • Removed the unused appliedMode field from the function signature, its call site in main.ts, and the test.
  • ✅ Fixed: Session cookie name duplicated across independent modules
    • Extracted SESSION_COOKIE_NAME to Services/SessionCredentialService.ts and imported it in both ServerAuthPolicy.ts and the layer SessionCredentialService.ts, eliminating the duplicated constant.

Create PR

Or push these changes by commenting:

@cursor push d38c659f7a
Preview (d38c659f7a)
diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts
--- a/apps/desktop/src/desktopSettings.test.ts
+++ b/apps/desktop/src/desktopSettings.test.ts
@@ -50,7 +50,6 @@
         },
         {
           requestedMode: "network-accessible",
-          appliedMode: "local-only",
         },
       ),
     ).toEqual({

diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts
--- a/apps/desktop/src/desktopSettings.ts
+++ b/apps/desktop/src/desktopSettings.ts
@@ -14,7 +14,6 @@
   settings: DesktopSettings,
   input: {
     readonly requestedMode: DesktopServerExposureMode;
-    readonly appliedMode: DesktopServerExposureMode;
   },
 ): DesktopSettings {
   const persistedMode = input.requestedMode;

diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -237,7 +237,6 @@
   desktopServerExposureMode = exposure.mode;
   desktopSettings = setDesktopServerExposurePreference(desktopSettings, {
     requestedMode,
-    appliedMode: exposure.mode,
   });
   backendBindHost = exposure.bindHost;
   backendHttpUrl = exposure.localHttpUrl;

diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts
--- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts
+++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts
@@ -3,9 +3,8 @@
 
 import { ServerConfig } from "../../config.ts";
 import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
+import { SESSION_COOKIE_NAME } from "../Services/SessionCredentialService.ts";
 
-const SESSION_COOKIE_NAME = "t3_session";
-
 const isWildcardHost = (host: string | undefined): boolean =>
   host === "0.0.0.0" || host === "::" || host === "[::]";
 

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
@@ -6,6 +6,7 @@
 import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
 import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
 import {
+  SESSION_COOKIE_NAME,
   SessionCredentialError,
   SessionCredentialService,
   type IssuedSession,
@@ -21,7 +22,6 @@
 } from "../tokenCodec.ts";
 
 const SIGNING_SECRET_NAME = "server-signing-key";
-const SESSION_COOKIE_NAME = "t3_session";
 const DEFAULT_SESSION_TTL = Duration.days(30);
 const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
 

diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts
--- a/apps/server/src/auth/Services/SessionCredentialService.ts
+++ b/apps/server/src/auth/Services/SessionCredentialService.ts
@@ -81,6 +81,8 @@
   readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
 }
 
+export const SESSION_COOKIE_NAME = "t3_session";
+
 export class SessionCredentialService extends ServiceMap.Service<
   SessionCredentialService,
   SessionCredentialServiceShape

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

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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: Electron browser detection is unreachable dead code
    • Moved the Electron check before the Chrome check in inferBrowser so Electron's UA string (which contains 'Chrome/') is correctly identified as Electron.

Create PR

Or push these changes by commenting:

@cursor push bb1611aabd
Preview (bb1611aabd)
diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts
--- a/apps/server/src/auth/utils.ts
+++ b/apps/server/src/auth/utils.ts
@@ -67,10 +67,10 @@
   const normalized = userAgent;
   if (/Edg\//.test(normalized)) return "Edge";
   if (/OPR\//.test(normalized)) return "Opera";
+  if (/Electron\//.test(normalized)) return "Electron";
   if (/Firefox\//.test(normalized)) return "Firefox";
   if (/Chrome\//.test(normalized) || /CriOS\//.test(normalized)) return "Chrome";
   if (/Safari\//.test(normalized) && !/Chrome\//.test(normalized)) return "Safari";
-  if (/Electron\//.test(normalized)) return "Electron";
   return undefined;
 }

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

- Stop retrying subscriptions after application-level stream failures
- Keep retry loops for transport disconnects only
- Add tests for both failure paths
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.

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

Autofix Details

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

  • ✅ Fixed: Fragile string comparison routes credential consume control flow
    • Added a structured reason discriminant field ('not-found' | 'expired') to the ConsumeResult error variant and replaced the fragile message string comparison with a check on seededResult.reason !== "not-found".

Create PR

Or push these changes by commenting:

@cursor push c52134326d
Preview (c52134326d)
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
@@ -21,6 +21,7 @@
 type ConsumeResult =
   | {
       readonly _tag: "error";
+      readonly reason: "not-found" | "expired";
       readonly error: BootstrapCredentialError;
     }
   | {
@@ -178,6 +179,7 @@
             return [
               {
                 _tag: "error",
+                reason: "not-found",
                 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
               },
               current,
@@ -190,6 +192,7 @@
             return [
               {
                 _tag: "error",
+                reason: "expired",
                 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
               },
               next,
@@ -227,7 +230,7 @@
       if (seededResult._tag === "success") {
         return seededResult.grant;
       }
-      if (seededResult.error.message !== "Unknown bootstrap credential.") {
+      if (seededResult.reason !== "not-found") {
         return yield* seededResult.error;
       }

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

juliusmarminge and others added 2 commits April 9, 2026 00:53
- Switch browser component test mocks to async `vi.importActual`
- Keep real `@tanstack/react-query` and environment exports available to tests
…parison in credential consume flow

Replace exact message string comparison with a 'reason' discriminant field
on the ConsumeResult error variant ('not-found' | 'expired') to determine
whether to fall through from in-memory seeded grants to the database-backed
pairing link lookup. This prevents the database lookup from silently breaking
if the error message text is ever changed.
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push c521343

…parison in credential consume flow

Replace exact message string comparison with a 'reason' discriminant field
on the ConsumeResult error variant ('not-found' | 'expired') to determine
whether to fall through from in-memory seeded grants to the database-backed
pairing link lookup. This prevents the database lookup from silently breaking
if the error message text is ever changed.

Applied via @cursor push command
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.

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

Fix All in Cursor

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

  • ✅ Fixed: Browser/OS inference skips lowercasing unlike device type inference
    • Added .toLowerCase() to the normalized assignment in both inferBrowser and inferOs, and updated all regex patterns to use lowercase to match the existing pattern in inferDeviceType.

Create PR

Or push these changes by commenting:

@cursor push 726137b8ff
Preview (726137b8ff)
diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts
--- a/apps/server/src/auth/utils.ts
+++ b/apps/server/src/auth/utils.ts
@@ -64,13 +64,13 @@
   if (!userAgent) {
     return undefined;
   }
-  const normalized = userAgent;
-  if (/Edg\//.test(normalized)) return "Edge";
-  if (/OPR\//.test(normalized)) return "Opera";
-  if (/Firefox\//.test(normalized)) return "Firefox";
-  if (/Chrome\//.test(normalized) || /CriOS\//.test(normalized)) return "Chrome";
-  if (/Safari\//.test(normalized) && !/Chrome\//.test(normalized)) return "Safari";
-  if (/Electron\//.test(normalized)) return "Electron";
+  const normalized = userAgent.toLowerCase();
+  if (/edg\//.test(normalized)) return "Edge";
+  if (/opr\//.test(normalized)) return "Opera";
+  if (/firefox\//.test(normalized)) return "Firefox";
+  if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
+  if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
+  if (/electron\//.test(normalized)) return "Electron";
   return undefined;
 }
 
@@ -78,12 +78,12 @@
   if (!userAgent) {
     return undefined;
   }
-  const normalized = userAgent;
-  if (/iPhone|iPad|iPod/.test(normalized)) return "iOS";
-  if (/Android/.test(normalized)) return "Android";
-  if (/Mac OS X|Macintosh/.test(normalized)) return "macOS";
-  if (/Windows NT/.test(normalized)) return "Windows";
-  if (/Linux/.test(normalized)) return "Linux";
+  const normalized = userAgent.toLowerCase();
+  if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
+  if (/android/.test(normalized)) return "Android";
+  if (/mac os x|macintosh/.test(normalized)) return "macOS";
+  if (/windows nt/.test(normalized)) return "Windows";
+  if (/linux/.test(normalized)) return "Linux";
   return undefined;
 }

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

Reviewed by Cursor Bugbot for commit 63460ab. Configure here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 726137b

cursoragent and others added 2 commits April 9, 2026 08:00
- improve remote host normalization and snapshot recovery
- sync browser chrome and terminal colors with active theme
- add QR code and runtime store coverage
juliusmarminge and others added 4 commits April 9, 2026 02:08
- Extract the empty chat state into a shared component
- Sync theme-color and app chrome to the active surface
- Fix Electron auth metadata detection for user agents
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge merged commit b7559c4 into main Apr 9, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/remote-auth-pairing branch April 9, 2026 17:18
rororowyourboat added a commit to rororowyourboat/t3code that referenced this pull request Apr 9, 2026
…threadId (#2)

* Raise slow RPC ack warning threshold to 15s (pingdotgg#1760)

* Use active worktree path for workspace saves (pingdotgg#1762)

* Stream git status updates over WebSocket (pingdotgg#1763)

Co-authored-by: codex <codex@users.noreply.github.com>

* fix(web): unwrap windows shell command wrappers (pingdotgg#1719)

* Rename "Chat" to "Build" in interaction mode toggle (pingdotgg#1769)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

* Assign default capabilities to Codex custom models (pingdotgg#1793)

* Add project rename support in the sidebar (pingdotgg#1798)

* Support multi-select pending user inputs (pingdotgg#1797)

* Add Zed support to Open actions via editor command aliases (pingdotgg#1303)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>

* Closes pingdotgg#1795 - Support building and developing in a devcontainer (pingdotgg#1791)

* Add explicit timeouts to CI and release workflows (pingdotgg#1825)

* fix(web): distinguish singular/plural in pending action submit label (pingdotgg#1826)

* Refactor web stores into atomic slices ready to split ChatView (pingdotgg#1708)

* Add VSCode Insiders and VSCodium icons (pingdotgg#1847)

* Prepare datamodel for multi-environment (pingdotgg#1765)

Co-authored-by: justsomelegs <145564979+justsomelegs@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

* Implement server auth bootstrap and pairing flow (pingdotgg#1768)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

* Use dev proxy for loopback auth and environment requests (pingdotgg#1853)

* Refresh local git status on turn completion (pingdotgg#1821)

Co-authored-by: codex <codex@users.noreply.github.com>

* fix(desktop): add Copy Link action for chat links (pingdotgg#1835)

* fix: map runtime modes to correct permission levels (pingdotgg#1587)

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>

* Fix persisted composer image hydration typo (pingdotgg#1831)

* Clarify environment and workspace picker labels (pingdotgg#1854)

* Scope git toast state by thread ref (pingdotgg#1855)

* fix build (pingdotgg#1859)

* Stabilize keybindings toast stream setup (pingdotgg#1860)

Co-authored-by: Julius Marminge <julius@macmini.local>

* feat(web): add embeddable thread route for canvas tile hosts

Adds /embed/thread/:environmentId/:threadId — a standalone route that
renders the existing ChatView without the app sidebar chrome. This is the
iframe target for t3-canvas agent shapes (see rororowyourboat/t3-canvas#3).

- New file-based route embed.thread.\$environmentId.\$threadId.tsx
- __root.tsx bypasses AppSidebarLayout for any /embed/* pathname so the
  environment connection + websocket surface + toasts still initialize
  but the sidebar/diff/plan chrome does not render
- minimal=1 search param is parsed and wired to a data attribute on the
  container for future targeted CSS; chrome hiding (BranchToolbar,
  PlanSidebar, ThreadTerminalDrawer) stays as a follow-up pass
- routeTree.gen.ts regenerated by the @tanstack/router-plugin

---------

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: legs <145564979+justsomelegs@users.noreply.github.com>
Co-authored-by: sonder <168988030+heysonder@users.noreply.github.com>
Co-authored-by: Adem Ben Abdallah <96244394+AdemBenAbdallah@users.noreply.github.com>
Co-authored-by: Kyle Gottfried <6462596+Spitfire1900@users.noreply.github.com>
Co-authored-by: Jacob <589761+jvzijp@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Klemencina <56873773+Klemencina@users.noreply.github.com>
Co-authored-by: Oskar Sekutowicz <me.oski646@gmail.com>
Co-authored-by: Noxire <59626436+noxire-dev@users.noreply.github.com>
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.

2 participants