authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 10:53:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 17:53:46+00:00
log5b3b31b6e6846eb0509fb2e7c31646ae843c5609
tree289c883b2ee04e7c7902db4ae37835048b595266
parentb7559c467803f923e59a287c112b4c92b96e15c7
signature Signed by PGP key B5690EEEBB952194

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


11 files changed, 231 insertions(+), 45 deletions(-)

apps/web/src/authBootstrap.test.ts+41
......@@ -175,6 +175,47 @@ describe("resolveInitialServerAuthGateState", () => {
175175 });
176176 });
177177
178 it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => {
179 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
180 sessionResponse({
181 authenticated: false,
182 auth: {
183 policy: "desktop-managed-local",
184 bootstrapMethods: ["desktop-bootstrap"],
185 sessionMethods: ["browser-session-cookie"],
186 sessionCookieName: "t3_session",
187 },
188 }),
189 );
190 vi.stubGlobal("fetch", fetchMock);
191 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
192
193 const testWindow = installTestBrowser("http://127.0.0.1:5733/");
194 testWindow.desktopBridge = {
195 getLocalEnvironmentBootstrap: () => ({
196 label: "Local environment",
197 httpBaseUrl: "http://127.0.0.1:3773",
198 wsBaseUrl: "ws://127.0.0.1:3773",
199 }),
200 } as DesktopBridge;
201
202 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
203
204 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
205 status: "requires-auth",
206 auth: {
207 policy: "desktop-managed-local",
208 bootstrapMethods: ["desktop-bootstrap"],
209 sessionMethods: ["browser-session-cookie"],
210 sessionCookieName: "t3_session",
211 },
212 });
213
214 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", {
215 credentials: "include",
216 });
217 });
218
178219 it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => {
179220 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
180221 sessionResponse({
apps/web/src/components/ChatView.tsx+27-25
......@@ -31,7 +31,7 @@ import {
3131import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
3232import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3333import { truncate } from "@t3tools/shared/String";
34import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
34import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3535import { useQuery } from "@tanstack/react-query";
3636import { useDebouncedValue } from "@tanstack/react-pacer";
3737import { useNavigate, useSearch } from "@tanstack/react-router";
......@@ -491,7 +491,7 @@ interface PersistentThreadTerminalDrawerProps {
491491 onAddTerminalContext: (selection: TerminalContextSelection) => void;
492492}
493493
494function PersistentThreadTerminalDrawer({
494const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
495495 threadRef,
496496 threadId,
497497 visible,
......@@ -650,7 +650,7 @@ function PersistentThreadTerminalDrawer({
650650 />
651651 </div>
652652 );
653}
653});
654654
655655export default function ChatView(props: ChatViewProps) {
656656 const { environmentId, threadId, routeKind } = props;
......@@ -867,6 +867,14 @@ export default function ChatView(props: ChatViewProps) {
867867 [draftThreadsByThreadKey],
868868 );
869869 const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
870 const mountedTerminalThreadRefs = useMemo(
871 () =>
872 mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
873 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
874 return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : [];
875 }),
876 [mountedTerminalThreadKeys],
877 );
870878
871879 const setPrompt = useCallback(
872880 (nextPrompt: string) => {
......@@ -4850,28 +4858,22 @@ export default function ChatView(props: ChatViewProps) {
48504858 </div>
48514859 {/* end horizontal flex container */}
48524860
4853 {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
4854 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
4855 if (!mountedThreadRef) {
4856 return [];
4857 }
4858 return [
4859 <PersistentThreadTerminalDrawer
4860 key={mountedThreadKey}
4861 threadRef={mountedThreadRef}
4862 threadId={mountedThreadRef.threadId}
4863 visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
4864 launchContext={
4865 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
4866 }
4867 focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
4868 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4869 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4870 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4871 onAddTerminalContext={addTerminalContextToDraft}
4872 />,
4873 ];
4874 })}
4861 {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
4862 <PersistentThreadTerminalDrawer
4863 key={mountedThreadKey}
4864 threadRef={mountedThreadRef}
4865 threadId={mountedThreadRef.threadId}
4866 visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen}
4867 launchContext={
4868 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
4869 }
4870 focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
4871 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4872 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4873 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4874 onAddTerminalContext={addTerminalContextToDraft}
4875 />
4876 ))}
48754877
48764878 {expandedImage && expandedImageItem && (
48774879 <div
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+26
......@@ -259,6 +259,32 @@ describe("TerminalViewport", () => {
259259 }
260260 });
261261
262 it("does not reopen the terminal when the scoped thread reference values stay the same", async () => {
263 const environment = createEnvironmentApi();
264 environmentApiById.set("environment-a", environment);
265
266 const mounted = await mountTerminalViewport({
267 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
268 });
269
270 try {
271 await vi.waitFor(() => {
272 expect(environment.terminal.open).toHaveBeenCalledTimes(1);
273 });
274
275 await mounted.rerender({
276 threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
277 });
278
279 await vi.waitFor(() => {
280 expect(environment.terminal.open).toHaveBeenCalledTimes(1);
281 });
282 expect(terminalDisposeSpy).not.toHaveBeenCalled();
283 } finally {
284 await mounted.cleanup();
285 }
286 });
287
262288 it("uses the drawer surface colors for the terminal theme", async () => {
263289 const environment = createEnvironmentApi();
264290 environmentApiById.set("environment-a", environment);
apps/web/src/components/ThreadTerminalDrawer.tsx+5-4
......@@ -268,6 +268,7 @@ export function TerminalViewport({
268268 const containerRef = useRef<HTMLDivElement>(null);
269269 const terminalRef = useRef<Terminal | null>(null);
270270 const fitAddonRef = useRef<FitAddon | null>(null);
271 const environmentId = threadRef.environmentId;
271272 const hasHandledExitRef = useRef(false);
272273 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
273274 const selectionGestureActiveRef = useRef(false);
......@@ -289,7 +290,7 @@ export function TerminalViewport({
289290 if (!mount) return;
290291
291292 let disposed = false;
292 const api = readEnvironmentApi(threadRef.environmentId);
293 const api = readEnvironmentApi(environmentId);
293294 const localApi = readLocalApi();
294295 if (!api || !localApi) return;
295296
......@@ -706,7 +707,7 @@ export function TerminalViewport({
706707 // autoFocus is intentionally omitted;
707708 // it is only read at mount time and must not trigger terminal teardown/recreation.
708709 // eslint-disable-next-line react-hooks/exhaustive-deps
709 }, [cwd, runtimeEnv, terminalId, threadId, threadRef]);
710 }, [cwd, environmentId, runtimeEnv, terminalId, threadId]);
710711
711712 useEffect(() => {
712713 if (!autoFocus) return;
......@@ -721,7 +722,7 @@ export function TerminalViewport({
721722 }, [autoFocus, focusRequestId]);
722723
723724 useEffect(() => {
724 const api = readEnvironmentApi(threadRef.environmentId);
725 const api = readEnvironmentApi(environmentId);
725726 const terminal = terminalRef.current;
726727 const fitAddon = fitAddonRef.current;
727728 if (!api || !terminal || !fitAddon) return;
......@@ -743,7 +744,7 @@ export function TerminalViewport({
743744 return () => {
744745 window.cancelAnimationFrame(frame);
745746 };
746 }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
747 }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]);
747748 return (
748749 <div
749750 ref={containerRef}
apps/web/src/components/settings/ConnectionsSettings.tsx+3-12
......@@ -52,9 +52,10 @@ import {
5252 revokeOtherServerClientSessions,
5353 revokeServerClientSession,
5454 revokeServerPairingLink,
55 isLoopbackHostname,
5556 type ServerClientSessionRecord,
5657 type ServerPairingLinkRecord,
57} from "../../environments/primary";
58} from "~/environments/primary";
5859import type { WsRpcClient } from "~/rpc/wsRpcClient";
5960import {
6061 type SavedEnvironmentRecord,
......@@ -65,7 +66,7 @@ import {
6566 getPrimaryEnvironmentConnection,
6667 reconnectSavedEnvironment,
6768 removeSavedEnvironment,
68} from "../../environments/runtime";
69} from "~/environments/runtime";
6970
7071const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
7172 dateStyle: "medium",
......@@ -253,16 +254,6 @@ function resolveCurrentOriginPairingUrl(credential: string): string {
253254 return setPairingTokenOnUrl(url, credential).toString();
254255}
255256
256function isLoopbackHostname(hostname: string): boolean {
257 const normalized = hostname.trim().toLowerCase();
258 return (
259 normalized === "localhost" ||
260 normalized === "127.0.0.1" ||
261 normalized === "::1" ||
262 normalized === "[::1]"
263 );
264}
265
266257type PairingLinkListRowProps = {
267258 pairingLink: ServerPairingLinkRecord;
268259 endpointUrl: string | null | undefined;
apps/web/src/environments/primary/bootstrap.test.ts+23
......@@ -118,4 +118,27 @@ describe("environmentBootstrap", () => {
118118 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
119119 expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment");
120120 });
121
122 it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => {
123 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
124 vi.stubGlobal("fetch", fetchMock);
125 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
126 vi.stubGlobal("window", {
127 location: new URL("http://127.0.0.1:5733/"),
128 history: {
129 replaceState: vi.fn(),
130 },
131 desktopBridge: {
132 getLocalEnvironmentBootstrap: () => ({
133 label: "Local environment",
134 httpBaseUrl: "http://127.0.0.1:3773",
135 wsBaseUrl: "ws://127.0.0.1:3773",
136 bootstrapToken: "desktop-bootstrap-token",
137 }),
138 },
139 });
140
141 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
142 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/.well-known/t3/environment");
143 });
121144});
apps/web/src/environments/primary/index.ts+1-1
......@@ -32,4 +32,4 @@ export {
3232 __resetServerAuthBootstrapForTests,
3333} from "./auth";
3434
35export { resolvePrimaryEnvironmentHttpUrl } from "./target";
35export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
apps/web/src/environments/primary/target.ts+40-1
......@@ -6,6 +6,8 @@ export interface PrimaryEnvironmentTarget {
66 readonly target: KnownEnvironment["target"];
77}
88
9const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
10
911function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null {
1012 return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null;
1113}
......@@ -14,6 +16,43 @@ function normalizeBaseUrl(rawValue: string): string {
1416 return new URL(rawValue, window.location.origin).toString();
1517}
1618
19function normalizeHostname(hostname: string): string {
20 return hostname
21 .trim()
22 .toLowerCase()
23 .replace(/^\[(.*)\]$/, "$1");
24}
25
26export function isLoopbackHostname(hostname: string): boolean {
27 return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
28}
29
30function resolveHttpRequestBaseUrl(httpBaseUrl: string): string {
31 const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim();
32 if (!configuredDevServerUrl) {
33 return httpBaseUrl;
34 }
35
36 const currentUrl = new URL(window.location.href);
37 const targetUrl = new URL(httpBaseUrl);
38 const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin);
39
40 const isCurrentOriginDevServer =
41 (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
42 currentUrl.origin === devServerUrl.origin;
43
44 if (
45 !isCurrentOriginDevServer ||
46 currentUrl.origin === targetUrl.origin ||
47 !isLoopbackHostname(currentUrl.hostname) ||
48 !isLoopbackHostname(targetUrl.hostname)
49 ) {
50 return httpBaseUrl;
51 }
52
53 return currentUrl.origin;
54}
55
1756function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
1857 const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim();
1958 const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim();
......@@ -86,7 +125,7 @@ export function resolvePrimaryEnvironmentHttpUrl(
86125 throw new Error("Unable to resolve the primary environment HTTP base URL.");
87126 }
88127
89 const url = new URL(primaryTarget.target.httpBaseUrl);
128 const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl));
90129 url.pathname = pathname;
91130 if (searchParams) {
92131 url.search = new URLSearchParams(searchParams).toString();
apps/web/src/environments/runtime/service.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vitest";
2
3import { shouldApplyTerminalEvent } from "./service";
4
5describe("shouldApplyTerminalEvent", () => {
6 it("applies terminal events for draft-only threads", () => {
7 expect(
8 shouldApplyTerminalEvent({
9 serverThreadArchivedAt: undefined,
10 hasDraftThread: true,
11 }),
12 ).toBe(true);
13 });
14
15 it("drops terminal events for unknown threads", () => {
16 expect(
17 shouldApplyTerminalEvent({
18 serverThreadArchivedAt: undefined,
19 hasDraftThread: false,
20 }),
21 ).toBe(false);
22 });
23
24 it("drops terminal events for archived server threads even if a draft exists", () => {
25 expect(
26 shouldApplyTerminalEvent({
27 serverThreadArchivedAt: "2026-04-09T00:00:00.000Z",
28 hasDraftThread: true,
29 }),
30 ).toBe(false);
31 });
32
33 it("applies terminal events for active server threads", () => {
34 expect(
35 shouldApplyTerminalEvent({
36 serverThreadArchivedAt: null,
37 hasDraftThread: false,
38 }),
39 ).toBe(true);
40 });
41});
apps/web/src/environments/runtime/service.ts+20-2
......@@ -196,6 +196,17 @@ function reconcileSnapshotDerivedState() {
196196 useTerminalStateStore.getState().removeOrphanedTerminalStates(activeThreadKeys);
197197}
198198
199export function shouldApplyTerminalEvent(input: {
200 serverThreadArchivedAt: string | null | undefined;
201 hasDraftThread: boolean;
202}): boolean {
203 if (input.serverThreadArchivedAt !== undefined) {
204 return input.serverThreadArchivedAt === null;
205 }
206
207 return input.hasDraftThread;
208}
209
199210function applyRecoveredEventBatch(
200211 events: ReadonlyArray<OrchestrationEvent>,
201212 environmentId: EnvironmentId,
......@@ -266,8 +277,15 @@ function createEnvironmentConnectionHandlers() {
266277 },
267278 applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => {
268279 const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe(event.threadId));
269 const thread = selectThreadByRef(useStore.getState(), threadRef);
270 if (!thread || thread.archivedAt !== null) {
280 const serverThread = selectThreadByRef(useStore.getState(), threadRef);
281 const hasDraftThread =
282 useComposerDraftStore.getState().getDraftThreadByRef(threadRef) !== null;
283 if (
284 !shouldApplyTerminalEvent({
285 serverThreadArchivedAt: serverThread?.archivedAt,
286 hasDraftThread,
287 })
288 ) {
271289 return;
272290 }
273291 useTerminalStateStore.getState().applyTerminalEvent(threadRef, event);
apps/web/vite.config.ts+4
......@@ -72,6 +72,10 @@ export default defineConfig({
7272 ...(devProxyTarget
7373 ? {
7474 proxy: {
75 "/.well-known": {
76 target: devProxyTarget,
77 changeOrigin: true,
78 },
7579 "/api": {
7680 target: devProxyTarget,
7781 changeOrigin: true,