Skip to content

feat: Hosted Frontend, Tailscale Integration & SSH Lancher#2361

Open
juliusmarminge wants to merge 43 commits intomainfrom
t3code/hosted-pairing-ui
Open

feat: Hosted Frontend, Tailscale Integration & SSH Lancher#2361
juliusmarminge wants to merge 43 commits intomainfrom
t3code/hosted-pairing-ui

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 27, 2026

Note

Medium Risk
Touches networking and remote-access plumbing (Tailscale Serve lifecycle, SSH tunneling/askpass, hosted pairing) and adds new IPC surfaces, which could impact connectivity and auth flows if misconfigured.

Overview
Adds first-class remote connectivity primitives across desktop/server/web: advertised endpoints (loopback/LAN/custom HTTPS + Tailscale-derived) are surfaced via new desktop IPC, and saved environments now persist optional desktopSsh metadata.

Desktop gains Tailscale Serve preference (with port validation) plus endpoint discovery, and introduces an SSH launch/forwarding bridge exposed over IPC with renderer-driven password prompts (new SshPasswordPromptDialog) and packaged askpass/remote-launch scripts.

Server adds --tailscale-serve / --tailscale-serve-port (and env/bootstrap equivalents) and configures/disables tailscale serve on startup/shutdown; Web adds a hosted /pair?host=...#token=... flow that saves environments client-side and improves UX when a non-primary environment is disconnected (reconnect banner, disabled send/actions, safer favicon URL handling).

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

Note

Add hosted frontend, Tailscale serve integration, and desktop SSH environment launcher

  • Introduces a hosted static web app mode where the frontend can run without a paired backend, supporting hosted pairing URLs that auto-add backend environments in-browser via HostedPairingRouteSurface
  • Adds new @t3tools/tailscale and @t3tools/ssh packages implementing Tailscale status parsing/serve management and SSH tunnel orchestration (host discovery, remote T3 server launch, password prompt handling, port selection)
  • Desktop app now manages SSH environments via DesktopSshEnvironmentBridge, exposing IPC for SSH lifecycle, password prompts, Tailscale serve toggling, and advertised endpoint resolution
  • Introduces AdvertisedEndpoint model in @t3tools/contracts and @t3tools/client-runtime with provider/reachability/compatibility metadata; connections settings UI gains endpoint selection, default preference persistence, hosted pairing links, and Tailscale HTTPS setup/disable flows
  • ChatView and ChatComposer now detect disconnected saved environments, disable sending/reverting, and surface a reconnect action
  • WebSocket connection state tracks a connectionLabel used in disconnect/reconnect toasts; intentional closes (e.g. dispose) are no longer recorded as disconnects
  • Risk: server startup now conditionally calls ensureTailscaleServe when tailscaleServeEnabled=true; failures are caught as warnings but the Tailscale CLI must be present and reachable

Macroscope summarized 9597a7e.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 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: 21fb0d0b-c8cf-456e-8d20-6f522c7d4838

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/hosted-pairing-ui

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

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 27, 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 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Hosted pairing URL fallback to desktop URL is unreachable
    • Added isHostedPairingCompatible check so resolveHostedPairingUrl returns null for non-HTTPS endpoints, allowing the ?? operator to correctly fall through to resolveDesktopPairingUrl for HTTP LAN backends.

Create PR

Or push these changes by commenting:

@cursor push 428913d5d5
Preview (428913d5d5)
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -250,7 +250,18 @@
   return setPairingTokenOnUrl(url, credential).toString();
 }
 
-function resolveHostedPairingUrl(endpointUrl: string, credential: string): string {
+function isHostedPairingCompatible(endpointUrl: string): boolean {
+  try {
+    return new URL(endpointUrl).protocol === "https:";
+  } catch {
+    return false;
+  }
+}
+
+function resolveHostedPairingUrl(endpointUrl: string, credential: string): string | null {
+  if (!isHostedPairingCompatible(endpointUrl)) {
+    return null;
+  }
   return buildHostedPairingUrl({
     host: endpointUrl,
     token: credential,

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

Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
Comment thread apps/web/src/environments/runtime/service.ts Outdated
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 27, 2026

Approvability

Verdict: Needs human review

3 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/hosted-pairing-ui branch from 34102d3 to 814c206 Compare April 27, 2026 02:31
Comment thread apps/web/src/routes/__root.tsx
juliusmarminge and others added 2 commits April 26, 2026 19:43
Co-authored-by: codex <codex@users.noreply.github.com>
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.

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: Cached no-RPC LocalApi causes failures in newly-accessible routes
    • Moved the fire-and-forget server.updateSettings RPC call inside the existing if (currentServerConfig) guard so it is only attempted when a server backend is actually connected, preventing unhandled rejections in hosted-static mode.

Create PR

Or push these changes by commenting:

@cursor push f485d6b4ba
Preview (f485d6b4ba)
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -165,9 +165,9 @@
       const currentServerConfig = getServerConfig();
       if (currentServerConfig) {
         applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch));
+        // Fire-and-forget RPC — push will reconcile on success
+        void ensureLocalApi().server.updateSettings(serverPatch);
       }
-      // Fire-and-forget RPC — push will reconcile on success
-      void ensureLocalApi().server.updateSettings(serverPatch);
     }
 
     if (Object.keys(clientPatch).length > 0) {

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

Comment thread apps/web/src/localApi.ts
@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented Apr 27, 2026

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

  • ✅ Fixed: Catch-all silently degrades desktop/self-hosted on transient failures
    • Narrowed the catch block to only enter hosted-static mode when the primary environment target source is 'window-origin' (the hosted app fallback), re-throwing errors for 'desktop-managed' and 'configured' targets so the error page is shown.

Create PR

Or push these changes by commenting:

@cursor push 519c3e126d
Preview (519c3e126d)
diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts
--- a/apps/web/src/environments/primary/index.ts
+++ b/apps/web/src/environments/primary/index.ts
@@ -32,4 +32,8 @@
   __resetServerAuthBootstrapForTests,
 } from "./auth";
 
-export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
+export {
+  resolvePrimaryEnvironmentHttpUrl,
+  isLoopbackHostname,
+  readPrimaryEnvironmentTarget,
+} from "./target";

diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -55,6 +55,7 @@
 import {
   ensurePrimaryEnvironmentReady,
   getPrimaryKnownEnvironment,
+  readPrimaryEnvironmentTarget,
   resolveInitialServerAuthGateState,
   updatePrimaryEnvironmentDescriptor,
 } from "../environments/primary";
@@ -81,7 +82,8 @@
         authGateState,
       };
     } catch (error) {
-      if (location.pathname === "/pair") {
+      const primaryTarget = readPrimaryEnvironmentTarget();
+      if (location.pathname === "/pair" || primaryTarget?.source !== "window-origin") {
         throw error;
       }

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

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: "Try again" button regresses paired state to error
    • Wrapped the 'Try again' button in a status !== "paired" conditional so it is hidden after successful pairing, preventing re-invocation of submitHostedPairingRequest which would throw on the already-connected environment.

Create PR

Or push these changes by commenting:

@cursor push 9d19673665
Preview (9d19673665)
diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx
--- a/apps/web/src/components/auth/PairingRouteSurface.tsx
+++ b/apps/web/src/components/auth/PairingRouteSurface.tsx
@@ -240,13 +240,15 @@
         ) : null}
 
         <div className="mt-6 flex flex-wrap gap-2">
-          <Button
-            disabled={status === "pairing"}
-            size="sm"
-            onClick={() => void submitHostedPairingRequest()}
-          >
-            {status === "pairing" ? "Pairing..." : "Try again"}
-          </Button>
+          {status !== "paired" ? (
+            <Button
+              disabled={status === "pairing"}
+              size="sm"
+              onClick={() => void submitHostedPairingRequest()}
+            >
+              {status === "pairing" ? "Pairing..." : "Try again"}
+            </Button>
+          ) : null}
           {status === "paired" ? (
             <Button size="sm" variant="outline" onClick={() => (window.location.href = "/")}>
               Open app

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

Comment thread apps/web/src/components/auth/PairingRouteSurface.tsx Outdated
juliusmarminge and others added 14 commits April 27, 2026 18:14
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>
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>
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>
- Discover SSH hosts and persist SSH targets
- Bootstrap tunneled SSH sessions with desktop password prompts
- Extend IPC and storage tests for SSH metadata
- Validate SSH targets and known-host parsing more strictly
- Retry desktop SSH session refresh on auth failures
- Preserve saved registry state when bearer persistence fails
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>
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.

Autofix Details

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

  • ✅ Fixed: Password dialog lacks Enter key form submission
    • Wrapped the dialog content in a element with an onSubmit handler and changed the Continue button to type="submit", enabling standard Enter-to-submit behavior.
  • ✅ Fixed: Custom HTTPS endpoints bypass mixed-content compatibility check
    • Removed the hardcoded hostedHttpsCompatibility: "compatible" override so that classifyHostedHttpsCompatibility runs automatically and correctly detects http:// URLs as "mixed-content-blocked".

Create PR

Or push these changes by commenting:

@cursor push 4dd2f66636
Preview (4dd2f66636)
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
@@ -162,7 +162,6 @@
         label: "Custom HTTPS",
         httpBaseUrl: customEndpointUrl,
         reachability: "public",
-        hostedHttpsCompatibility: "compatible",
         status: "unknown",
         description: "User-configured HTTPS endpoint for this desktop backend.",
       }),

diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
--- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
+++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
@@ -76,38 +76,43 @@
       }}
     >
       <DialogPopup className="max-w-md" showCloseButton={false}>
-        <DialogHeader>
-          <DialogTitle>SSH Password Required</DialogTitle>
-          <DialogDescription>
-            T3 needs your SSH password to connect to{" "}
-            {target ? <code>{target}</code> : "the remote host"}. The password is passed to the
-            local SSH process for this connection attempt and is not saved by T3 Code.
-          </DialogDescription>
-        </DialogHeader>
-        <DialogPanel className="space-y-3" scrollFade={false}>
-          <div className="space-y-2">
-            <p className="text-sm font-medium text-foreground">{currentRequest?.prompt}</p>
-            <Input
-              ref={inputRef}
-              autoComplete="current-password"
-              name="ssh-password"
-              type="password"
-              value={password}
-              onChange={(event) => setPassword(event.target.value)}
-            />
-          </div>
-          <p className="text-sm text-muted-foreground">
-            Use SSH keys to avoid repeated password prompts on new SSH sessions.
-          </p>
-        </DialogPanel>
-        <DialogFooter>
-          <Button variant="outline" onClick={() => void respond(null)}>
-            Cancel
-          </Button>
-          <Button onClick={() => void respond(password)} type="button">
-            Continue
-          </Button>
-        </DialogFooter>
+        <form
+          onSubmit={(event) => {
+            event.preventDefault();
+            void respond(password);
+          }}
+        >
+          <DialogHeader>
+            <DialogTitle>SSH Password Required</DialogTitle>
+            <DialogDescription>
+              T3 needs your SSH password to connect to{" "}
+              {target ? <code>{target}</code> : "the remote host"}. The password is passed to the
+              local SSH process for this connection attempt and is not saved by T3 Code.
+            </DialogDescription>
+          </DialogHeader>
+          <DialogPanel className="space-y-3" scrollFade={false}>
+            <div className="space-y-2">
+              <p className="text-sm font-medium text-foreground">{currentRequest?.prompt}</p>
+              <Input
+                ref={inputRef}
+                autoComplete="current-password"
+                name="ssh-password"
+                type="password"
+                value={password}
+                onChange={(event) => setPassword(event.target.value)}
+              />
+            </div>
+            <p className="text-sm text-muted-foreground">
+              Use SSH keys to avoid repeated password prompts on new SSH sessions.
+            </p>
+          </DialogPanel>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => void respond(null)}>
+              Cancel
+            </Button>
+            <Button type="submit">Continue</Button>
+          </DialogFooter>
+        </form>
       </DialogPopup>
     </Dialog>
   );

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

Comment thread apps/web/src/components/desktop/SshPasswordPromptDialog.tsx Outdated
Comment thread apps/desktop/src/serverExposure.ts
- add desktop and server settings for hosted HTTPS pairing
- resolve and probe MagicDNS endpoints via shared Tailscale helpers
- update remote pairing docs and UI to surface the new setup flow
Comment thread apps/desktop/src/desktopSettings.ts
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 all 3 issues found in the latest run.

  • ✅ Fixed: Hosted pairing token stripped before retry can re-read URL
    • Added a tokenSubmittedRef that tracks whether the one-time token was already sent to the server; retries now show a distinct message asking the user to request a new pairing link instead of silently reusing a consumed token.
  • ✅ Fixed: Password prompt queue corrupted by rapid double-click
    • Added an isResponding state guard that prevents concurrent invocations of respond and disables both buttons while the IPC call is in-flight, preventing double-click from dequeuing a prompt without resolving it.
  • ✅ Fixed: Redundant setDesktopTailscaleServePreference call in apply path
    • Refactored applyDesktopTailscaleServeEnabled to accept the already-resolved DesktopSettings directly from the IPC handler, eliminating the redundant re-derivation.

Create PR

Or push these changes by commenting:

@cursor push 614ff021d2
Preview (614ff021d2)
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
@@ -402,13 +402,14 @@
   return getDesktopServerExposureState();
 }
 
-async function applyDesktopTailscaleServeEnabled(input: {
-  readonly enabled: boolean;
-  readonly port?: number;
-}): Promise<DesktopServerExposureState> {
-  desktopSettings = setDesktopTailscaleServePreference(desktopSettings, input);
+async function applyDesktopTailscaleServeEnabled(
+  resolvedSettings: typeof desktopSettings,
+): Promise<DesktopServerExposureState> {
+  desktopSettings = resolvedSettings;
   writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
-  relaunchDesktopApp(input.enabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled");
+  relaunchDesktopApp(
+    desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
+  );
   return getDesktopServerExposureState();
 }
 
@@ -1751,10 +1752,7 @@
     if (nextSettings === desktopSettings) {
       return getDesktopServerExposureState();
     }
-    return applyDesktopTailscaleServeEnabled({
-      enabled: input.enabled,
-      port: nextSettings.tailscaleServePort,
-    });
+    return applyDesktopTailscaleServeEnabled(nextSettings);
   });
 
   ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);

diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx
--- a/apps/web/src/components/auth/PairingRouteSurface.tsx
+++ b/apps/web/src/components/auth/PairingRouteSurface.tsx
@@ -166,6 +166,7 @@
   const [status, setStatus] = useState<"pairing" | "paired" | "error">("pairing");
   const [message, setMessage] = useState("Connecting to this backend.");
   const submitAttemptedRef = useRef(false);
+  const tokenSubmittedRef = useRef(false);
 
   const submitHostedPairingRequest = useCallback(async () => {
     const request = hostedPairingRequestRef.current;
@@ -176,9 +177,18 @@
       return;
     }
 
+    if (tokenSubmittedRef.current) {
+      setStatus("error");
+      setMessage(
+        "The pairing token may have already been used. Please request a new pairing link.",
+      );
+      return;
+    }
+
     setStatus("pairing");
     setMessage("Connecting to this backend.");
 
+    tokenSubmittedRef.current = true;
     try {
       const record = await addSavedEnvironment({
         label: request.label,

diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
--- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
+++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
@@ -20,6 +20,7 @@
 export function SshPasswordPromptDialog() {
   const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]);
   const [password, setPassword] = useState("");
+  const [isResponding, setIsResponding] = useState(false);
   const currentRequest = queue[0] ?? null;
   const inputRef = useRef<HTMLInputElement | null>(null);
 
@@ -50,17 +51,20 @@
   }, [currentRequest]);
 
   const respond = async (nextPassword: string | null) => {
-    if (!currentRequest) {
+    if (!currentRequest || isResponding) {
       return;
     }
 
     const requestId = currentRequest.requestId;
+    setIsResponding(true);
     setQueue((currentQueue) => currentQueue.slice(1));
     setPassword("");
     try {
       await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword);
     } catch (error) {
       console.error("Failed to resolve SSH password prompt.", error);
+    } finally {
+      setIsResponding(false);
     }
   };
 
@@ -101,10 +105,10 @@
           </p>
         </DialogPanel>
         <DialogFooter>
-          <Button variant="outline" onClick={() => void respond(null)}>
+          <Button disabled={isResponding} variant="outline" onClick={() => void respond(null)}>
             Cancel
           </Button>
-          <Button onClick={() => void respond(password)} type="button">
+          <Button disabled={isResponding} onClick={() => void respond(password)} type="button">
             Continue
           </Button>
         </DialogFooter>

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

Comment thread apps/web/src/components/auth/PairingRouteSurface.tsx
Comment thread apps/web/src/components/desktop/SshPasswordPromptDialog.tsx
Comment thread apps/desktop/src/main.ts
- Preserve Tailscale Serve port when toggling exposure
- Prevent reused hosted pairing tokens and double SSH responses
- Refine endpoint compatibility for HTTP custom URLs
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.

Autofix Details

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

  • ✅ Fixed: Retry button never renders after hosted pairing failure
    • Reset tokenSubmittedRef.current to false in the catch block so the 'Try again' button condition (!tokenSubmittedRef.current) evaluates to true after a failure.
  • ✅ Fixed: Unhandled URL parse error crashes endpoint resolution
    • Wrapped the new URL() call in isHttpsEndpointUrl with a try-catch that returns false for malformed URLs, preventing a single bad entry from crashing the entire endpoint resolution.

Create PR

Or push these changes by commenting:

@cursor push ac015aaec3
Preview (ac015aaec3)
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
@@ -50,7 +50,11 @@
   !address.startsWith("127.") && !address.startsWith("169.254.");
 
 function isHttpsEndpointUrl(value: string): boolean {
-  return new URL(value).protocol === "https:";
+  try {
+    return new URL(value).protocol === "https:";
+  } catch {
+    return false;
+  }
 }
 
 export function resolveLanAdvertisedHost(

diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx
--- a/apps/web/src/components/auth/PairingRouteSurface.tsx
+++ b/apps/web/src/components/auth/PairingRouteSurface.tsx
@@ -196,6 +196,7 @@
       setStatus("paired");
       setMessage(`${record.label} is saved in this browser.`);
     } catch (error) {
+      tokenSubmittedRef.current = false;
       setStatus("error");
       setMessage(
         `${errorMessageFromUnknown(error)} Request a new pairing link before trying again.`,

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

Comment thread apps/web/src/components/auth/PairingRouteSurface.tsx
Comment thread apps/desktop/src/serverExposure.ts
Comment thread apps/web/src/components/auth/PairingRouteSurface.tsx
- Ignore invalid custom HTTPS endpoint URLs when building desktop exposure
- Reset pairing submission state after backend errors and clarify retry guidance
- Add coverage for malformed endpoint inputs
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: Misleading "Custom HTTPS" label for non-HTTPS endpoints
    • Made the label and description conditional on isHttpsEndpointUrl: HTTPS URLs get "Custom HTTPS" / "HTTPS endpoint" text, while non-HTTPS URLs get "Custom endpoint" / generic description.

Create PR

Or push these changes by commenting:

@cursor push 72d1449f64
Preview (72d1449f64)
diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts
--- a/apps/desktop/src/serverExposure.test.ts
+++ b/apps/desktop/src/serverExposure.test.ts
@@ -171,7 +171,7 @@
       },
       {
         id: "manual:http://desktop.example.test:3773",
-        label: "Custom HTTPS",
+        label: "Custom endpoint",
         provider: {
           id: "manual",
           label: "Manual",
@@ -187,7 +187,7 @@
         },
         source: "user",
         status: "unknown",
-        description: "User-configured HTTPS endpoint for this desktop backend.",
+        description: "User-configured endpoint for this desktop backend.",
       },
     ]);
   });

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
@@ -165,17 +165,18 @@
 
   for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
     try {
+      const isHttps = isHttpsEndpointUrl(customEndpointUrl);
       endpoints.push(
         createManualEndpoint({
           id: `manual:${customEndpointUrl}`,
-          label: "Custom HTTPS",
+          label: isHttps ? "Custom HTTPS" : "Custom endpoint",
           httpBaseUrl: customEndpointUrl,
           reachability: "public",
-          ...(isHttpsEndpointUrl(customEndpointUrl)
-            ? ({ hostedHttpsCompatibility: "compatible" } as const)
-            : {}),
+          ...(isHttps ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
           status: "unknown",
-          description: "User-configured HTTPS endpoint for this desktop backend.",
+          description: isHttps
+            ? "User-configured HTTPS endpoint for this desktop backend."
+            : "User-configured endpoint for this desktop backend.",
         }),
       );
     } catch {

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

);
} catch {
// Ignore malformed user-configured endpoints without dropping valid endpoints.
}
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.

Misleading "Custom HTTPS" label for non-HTTPS endpoints

Low Severity

All entries from customHttpsEndpointUrls receive the hard-coded label "Custom HTTPS" and description "User-configured HTTPS endpoint for this desktop backend." regardless of whether the URL actually uses HTTPS. A plain http:// URL parsed from T3CODE_DESKTOP_HTTPS_ENDPOINTS will be displayed to users with this misleading label, even though isHttpsEndpointUrl correctly classifies its compatibility as mixed-content-blocked. The label and description are protocol-agnostic despite the naming.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bae05b2. Configure here.

- Relax settings UI text assertions to match reachable URLs
- Add `packages/tailscale/package.json` to release smoke coverage
- Move SSH parsing and discovery logic into `@t3tools/ssh`
- Reuse the shared helpers from the desktop app and release smoke checks
- Add coverage for host discovery, parsing, and package spec resolution
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: Tailscale Serve uses config port before server binds
    • The tailscaleServeLayer now reads the actual bound port from HttpServer.address (matching the pattern used by runtimeStateLayer) instead of using config.port directly, so when port 0 is specified the real OS-assigned port is passed to ensureTailscaleServe.

Create PR

Or push these changes by commenting:

@cursor push c90ddf7775
Preview (c90ddf7775)
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -309,17 +309,24 @@
     );
     const tailscaleServeLayer = config.tailscaleServeEnabled
       ? Layer.effectDiscard(
-          ensureTailscaleServe({
-            localPort: config.port,
-            servePort: config.tailscaleServePort,
-            localHost: "127.0.0.1",
+          Effect.gen(function* () {
+            const server = yield* HttpServer.HttpServer;
+            const address = server.address;
+            const localPort =
+              typeof address !== "string" && "port" in address ? address.port : config.port;
+            yield* ensureTailscaleServe({
+              localPort,
+              servePort: config.tailscaleServePort,
+              localHost: "127.0.0.1",
+            }).pipe(
+              Effect.tap(() =>
+                Effect.logInfo("Tailscale Serve configured", {
+                  localPort,
+                  servePort: config.tailscaleServePort,
+                }),
+              ),
+            );
           }).pipe(
-            Effect.tap(() =>
-              Effect.logInfo("Tailscale Serve configured", {
-                localPort: config.port,
-                servePort: config.tailscaleServePort,
-              }),
-            ),
             Effect.catch((cause) =>
               Effect.logWarning("Failed to configure Tailscale Serve", {
                 cause,

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

Comment thread apps/server/src/server.ts
Comment thread packages/ssh/src/config.ts
- Add auth, command, config, and tunnel exports
- Update desktop SSH environment imports
- Add tests for auth and command helpers
- Avoid showing the hosted pairing flow outside the static app
- Keep the existing error boundary behavior for local builds
- Factor Tailscale command execution into a reusable helper
- Add tests for `tailscale serve off`
- Ensure hosted pairing UI cleans up Tailscale Serve after 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 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: Token stripped before reading hosted pairing request
    • Cached the result of readHostedPairingRequest() in a module-level variable so that remounts of HostedPairingRouteSurface read the originally-captured token instead of re-reading the already-stripped URL.

Create PR

Or push these changes by commenting:

@cursor push f4b4a82b5a
Preview (f4b4a82b5a)
diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx
--- a/apps/web/src/components/auth/PairingRouteSurface.tsx
+++ b/apps/web/src/components/auth/PairingRouteSurface.tsx
@@ -161,8 +161,16 @@
   );
 }
 
+let cachedHostedPairingRequest: ReturnType<typeof readHostedPairingRequest> | undefined;
+function readHostedPairingRequestOnce() {
+  if (cachedHostedPairingRequest === undefined) {
+    cachedHostedPairingRequest = readHostedPairingRequest();
+  }
+  return cachedHostedPairingRequest;
+}
+
 export function HostedPairingRouteSurface() {
-  const hostedPairingRequestRef = useRef(readHostedPairingRequest());
+  const hostedPairingRequestRef = useRef(readHostedPairingRequestOnce());
   const [status, setStatus] = useState<"pairing" | "paired" | "error">("pairing");
   const [message, setMessage] = useState("Connecting to this backend.");
   const submitAttemptedRef = useRef(false);

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

submitAttemptedRef.current = true;

stripPairingTokenFromUrl();
void submitHostedPairingRequest();
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 stripped before reading hosted pairing request

Medium Severity

readHostedPairingRequest() is called once via useRef at component mount (line 165), capturing the URL's host and token. However, stripPairingTokenFromUrl() is called inside the useEffect (line 213) which runs after the initial render. This works because the ref captures the value synchronously during the first render, before the effect fires. But the real concern is: if HostedPairingRouteSurface ever re-mounts (e.g., due to a parent key change or Suspense boundary), the ref will re-initialize after the token has been stripped from the URL, resulting in hostedPairingRequestRef.current being null and showing a misleading "missing host or token" error instead of attempting reconnection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0d692d0. Configure here.

- Move SSH auth, config, and tunnel logic into `packages/ssh`
- Wire `apps/desktop` to the shared Effect runtime
- Add tests for config, auth, and tunnel behavior
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: Windows askpass CMD references wrong PowerShell filename
    • Updated askpass-windows.cmd to invoke askpass-windows.ps1 instead of the nonexistent ssh-askpass.ps1.

Create PR

Or push these changes by commenting:

@cursor push 2befcb1e74
Preview (2befcb1e74)
diff --git a/apps/desktop/src/sshScripts/askpass-windows.cmd b/apps/desktop/src/sshScripts/askpass-windows.cmd
--- a/apps/desktop/src/sshScripts/askpass-windows.cmd
+++ b/apps/desktop/src/sshScripts/askpass-windows.cmd
@@ -1,2 +1,2 @@
 @echo off
-powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0askpass-windows.ps1" %*

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

Comment thread apps/desktop/src/sshScripts/askpass-windows.cmd
Comment on lines +130 to +133
async dispose(): Promise<void> {
await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
await this.runtime.dispose();
}
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.

🟠 High src/sshEnvironment.ts:130

In dispose(), if Scope.close(this.scope, Exit.void) throws, this.runtime.dispose() is never called and the ManagedRuntime leaks. Wrap Scope.close in a finally block so runtime.dispose() always executes.

-  async dispose(): Promise<void> {
-    await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
-    await this.runtime.dispose();
-  }
+  async dispose(): Promise<void> {
+    try {
+      await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
+    } finally {
+      await this.runtime.dispose();
+    }
+  }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/sshEnvironment.ts around lines 130-133:

In `dispose()`, if `Scope.close(this.scope, Exit.void)` throws, `this.runtime.dispose()` is never called and the `ManagedRuntime` leaks. Wrap `Scope.close` in a `finally` block so `runtime.dispose()` always executes.

Evidence trail:
apps/desktop/src/sshEnvironment.ts lines 131-134 at REVIEWED_COMMIT: `dispose()` method has two sequential awaits with no try/finally protection.

- Simplify add-environment dialog around remote and SSH pairing
- Auto-detect pairing URLs and improve SSH prompt handling
- Add coverage for tunnel and connection parsing behavior
- Support remote T3 runner selection for hosted pairing
- Surface SSH/environment disconnect states in the UI
- Improve websocket snapshot and password prompt error handling
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).

Fix All in Cursor

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

  • ✅ Fixed: Hardcoded developer-specific path as fallback value
    • Changed the fallback value from a developer-specific absolute path to an empty string so the guard correctly falls through to the production CLI resolution path when the env var is unset.

Create PR

Or push these changes by commenting:

@cursor push eb3bd611e5
Preview (eb3bd611e5)
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
@@ -124,8 +124,7 @@
 // for a built server entry, for example:
 // "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"
 const DEV_REMOTE_T3_SERVER_ENTRY_PATH =
-  process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ??
-  "/Users/julius/Developer/t3code/apps/server/dist/bin.mjs";
+  process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? "";
 const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
   isDevelopment,
   appVersion: app.getVersion(),

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

Reviewed by Cursor Bugbot for commit 8731582. Configure here.

Comment thread apps/desktop/src/main.ts
// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"
const DEV_REMOTE_T3_SERVER_ENTRY_PATH =
process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ??
"/Users/julius/Developer/t3code/apps/server/dist/bin.mjs";
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.

Hardcoded developer-specific path as fallback value

High Severity

DEV_REMOTE_T3_SERVER_ENTRY_PATH falls back to a developer-specific absolute path (/Users/julius/Developer/t3code/apps/server/dist/bin.mjs) when the env var is unset. Because this fallback is always non-empty, the condition DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0 on line 699 is always true in development mode, forcing all dev-mode SSH launches to use this hardcoded path instead of resolveRemoteT3CliPackageSpec. The fallback likely needs to be "" so the guard can fall through to the production CLI resolution path when no override is configured.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8731582. Configure here.

}
}

async dispose(): Promise<void> {
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.

🟡 Medium src/sshEnvironment.ts:344

After dispose() is called, the IPC handlers registered in registerIpcHandlers() remain active. If the renderer sends a message to ENSURE_SSH_ENVIRONMENT_CHANNEL or DISCONNECT_SSH_ENVIRONMENT_CHANNEL, the handler calls this.manager.ensureEnvironment() or this.manager.disconnectEnvironment() on the disposed manager, which throws because its internal runtime is already disposed. The dispose() method should unregister all IPC handlers via ipcMain.removeHandler() for each channel.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/sshEnvironment.ts around line 344:

After `dispose()` is called, the IPC handlers registered in `registerIpcHandlers()` remain active. If the renderer sends a message to `ENSURE_SSH_ENVIRONMENT_CHANNEL` or `DISCONNECT_SSH_ENVIRONMENT_CHANNEL`, the handler calls `this.manager.ensureEnvironment()` or `this.manager.disconnectEnvironment()` on the disposed manager, which throws because its internal runtime is already disposed. The `dispose()` method should unregister all IPC handlers via `ipcMain.removeHandler()` for each channel.

Evidence trail:
apps/desktop/src/sshEnvironment.ts lines 344-347 (dispose method - no removeHandler calls), lines 230-334 (registerIpcHandlers - registers handlers using ipcMain parameter but ipcMain is not stored as class field), lines 82-141 (DesktopSshEnvironmentManager class - dispose() calls this.runtime.dispose()), lines 118-136 (ensureEnvironment/disconnectEnvironment call this.runtime.runPromise)

juliusmarminge and others added 3 commits April 29, 2026 14:58
- Add structured logging around SSH command, tunnel, and pairing flows
- Let desktop SSH bootstrap failures propagate instead of timing out locally
- Update reconnect test to expect the underlying SSH timeout error
- tail remote server logs on readiness failures
- log tunnel command, PID, and local port state
- capture last HTTP probe failure on timeout
Co-authored-by: codex <codex@users.noreply.github.com>
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.

🟡 Medium

const onImplementPlanInNewThread = useCallback(async () => {

onImplementPlanInNewThread does not check activeEnvironmentUnavailable before dispatching commands. When the environment is disconnected but the API object is still stale (not yet cleaned up), readEnvironmentApi returns a non-undefined API, so the !api guard passes and the function attempts to dispatch thread.create and thread.turn.start to an unavailable environment. This will likely fail with confusing errors or hang. Consider adding the same activeEnvironmentUnavailable guard that was added to onSend.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 3069:

`onImplementPlanInNewThread` does not check `activeEnvironmentUnavailable` before dispatching commands. When the environment is disconnected but the API object is still stale (not yet cleaned up), `readEnvironmentApi` returns a non-`undefined` API, so the `!api` guard passes and the function attempts to dispatch `thread.create` and `thread.turn.start` to an unavailable environment. This will likely fail with confusing errors or hang. Consider adding the same `activeEnvironmentUnavailable` guard that was added to `onSend`.

Evidence trail:
apps/web/src/components/ChatView.tsx lines 2450-2457 (`onSend` includes `activeEnvironmentUnavailable` guard); apps/web/src/components/ChatView.tsx lines 3071-3081 (`onImplementPlanInNewThread` guard lacks `activeEnvironmentUnavailable`); apps/web/src/components/ChatView.tsx lines 3188-3200 (`useCallback` dependency array for `onImplementPlanInNewThread` also omits `activeEnvironmentUnavailable`); apps/web/src/components/ChatView.tsx lines 874-876 (definition of `activeEnvironmentUnavailable`)

- Add desktop SSH cancellation handling for environment setup
- Let chat reconnect a saved environment from the unavailable state
- Refine connections UI for hosted pairing links, network access, and favicon fallbacks
Comment on lines +1271 to +1283
Effect.gen(function* () {
if (tunnels.get(tunnelEntry.key) !== tunnelEntry) {
return;
}
yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {
...sshTargetLogFields(tunnelEntry.target),
key: tunnelEntry.key,
localPort: tunnelEntry.localPort,
remotePort: tunnelEntry.remotePort,
});
tunnels.delete(tunnelEntry.key);
const authSecret = authSecrets.get(tunnelEntry.key) ?? null;
yield* Effect.all(
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 src/tunnel.ts:1271

The scope finalizer at lines 1272-1281 contains a race condition. After the guard check tunnels.get(tunnelEntry.key) !== tunnelEntry returns false (meaning this entry is still current), the Effect.logDebug calls at lines 1275-1280 are yield points. If another fiber creates a new entry for the same key during this yield, line 1281 tunnels.delete(tunnelEntry.key) will delete the new entry instead of this one, leaving the new entry orphaned and its cleanup skipped.

Re-check the guard immediately before deletion: wrap the delete in if (tunnels.get(tunnelEntry.key) === tunnelEntry) to ensure we only delete our own entry.

      yield* Effect.gen(function* () {
        if (tunnels.get(tunnelEntry.key) !== tunnelEntry) {
          return;
        }
        yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {
          ...sshTargetLogFields(tunnelEntry.target),
          key: tunnelEntry.key,
          localPort: tunnelEntry.localPort,
          remotePort: tunnelEntry.remotePort,
        });
        tunnels.delete(tunnelEntry.key);
        const authSecret = authSecrets.get(tunnelEntry.key) ?? null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/ssh/src/tunnel.ts around lines 1271-1283:

The scope finalizer at lines 1272-1281 contains a race condition. After the guard check `tunnels.get(tunnelEntry.key) !== tunnelEntry` returns false (meaning this entry is still current), the `Effect.logDebug` calls at lines 1275-1280 are yield points. If another fiber creates a new entry for the same key during this yield, line 1281 `tunnels.delete(tunnelEntry.key)` will delete the *new* entry instead of this one, leaving the new entry orphaned and its cleanup skipped.

Re-check the guard immediately before deletion: wrap the delete in `if (tunnels.get(tunnelEntry.key) === tunnelEntry)` to ensure we only delete our own entry.

Evidence trail:
packages/ssh/src/tunnel.ts lines 1265 (tunnels.set), 1270-1281 (scope finalizer with guard check at 1272, yield point at 1275, unconditional delete at 1281) at REVIEWED_COMMIT. Only one tunnels.set call exists (confirmed via git_grep). Effect.gen yield* creates flatMap boundaries that are fiber interleaving points in Effect-TS runtime.

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