From d30859ca5cb9b11cd86dda1e19a9368aeb10adb7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:42:33 -0700 Subject: [PATCH 1/7] feat(auth): separate browser preview control scope --- apps/server/src/auth/RpcAuthorization.test.ts | 24 +++++++ apps/server/src/auth/RpcAuthorization.ts | 19 +++--- apps/server/src/server.test.ts | 68 +++++++++++++++++++ apps/web/src/browser/ElectronBrowserHost.tsx | 15 +++- apps/web/src/browser/useOpenLink.ts | 8 ++- apps/web/src/components/ChatMarkdown.tsx | 34 ++++++---- apps/web/src/components/ChatView.tsx | 36 +++++++--- apps/web/src/components/LegacySidebar.tsx | 9 ++- .../src/components/ThreadTerminalDrawer.tsx | 6 ++ .../src/components/files/FilePreviewPanel.tsx | 19 ++++-- .../preview/PreviewAutomationHosts.tsx | 9 ++- .../src/components/preview/PreviewPanel.tsx | 10 ++- .../settings/ConnectionsSettings.tsx | 6 ++ apps/web/src/routes/_chat.tsx | 10 ++- packages/contracts/src/auth.ts | 3 + 15 files changed, 226 insertions(+), 50 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index cd1f560f73ab..2834e0741381 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -2,6 +2,7 @@ import { AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, @@ -55,6 +56,29 @@ describe("RPC authorization scopes", () => { ); }); + it("separates preview control from observation", () => { + for (const method of [ + WS_METHODS.previewOpen, + WS_METHODS.previewNavigate, + WS_METHODS.previewResize, + WS_METHODS.previewRefresh, + WS_METHODS.previewClose, + WS_METHODS.previewReportStatus, + WS_METHODS.previewAutomationConnect, + WS_METHODS.previewAutomationRespond, + WS_METHODS.previewAutomationFocusHost, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope); + } + for (const method of [ + WS_METHODS.previewList, + WS_METHODS.subscribePreviewEvents, + WS_METHODS.subscribeDiscoveredLocalServers, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index cc1306f7200b..cab3410249d8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -7,6 +7,7 @@ import { AuthFilesystemWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, AuthSourceControlWriteScope, @@ -132,16 +133,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClose]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, - [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, - [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope, - [WS_METHODS.previewResize]: AuthOrchestrationOperateScope, - [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, - [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, + [WS_METHODS.previewOpen]: AuthPreviewOperateScope, + [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, + [WS_METHODS.previewResize]: AuthPreviewOperateScope, + [WS_METHODS.previewRefresh]: AuthPreviewOperateScope, + [WS_METHODS.previewClose]: AuthPreviewOperateScope, [WS_METHODS.previewList]: AuthOrchestrationReadScope, - [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, + [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationConnect]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationRespond]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationFocusHost]: AuthPreviewOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e27655f159ba..6a51b033faf1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -9,6 +9,7 @@ import { AuthAdministrativeScopes, AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + AuthPreviewOperateScope, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -518,6 +519,7 @@ const buildAppUnderTest = (options?: { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; terminalManager?: Partial; + previewManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; analyticsService?: Partial; @@ -912,6 +914,7 @@ const buildAppUnderTest = (options?: { subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), + ...options?.layers?.previewManager, }), Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), @@ -5666,6 +5669,71 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires an explicit preview grant for control and automation streams", () => + Effect.gen(function* () { + let refreshes = 0; + yield* buildAppUnderTest({ + layers: { + previewManager: { + refresh: () => + Effect.sync(() => { + refreshes += 1; + }), + }, + }, + }); + const threadId = ThreadId.makeUnsafe("preview-scope-thread"); + const host = { + clientId: "preview-scope-host", + environmentId: testEnvironmentDescriptor.environmentId, + } as const; + const legacyScopes = "orchestration:read orchestration:operate"; + for (const scope of [legacyScopes, `${legacyScopes} ${AuthPreviewOperateScope}`]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const previews = yield* client[WS_METHODS.previewList]({ threadId }); + assert.deepEqual(previews.sessions, []); + if (scope === legacyScopes) { + const errors = [ + yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.previewAutomationConnect](host).pipe( + Stream.runHead, + Effect.flip, + ), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, AuthPreviewOperateScope); + } + } + assert.equal(refreshes, 0); + } else { + yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" }); + const connected = yield* client[WS_METHODS.previewAutomationConnect](host).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(connected.type, "connected"); + assert.equal(refreshes, 1); + } + }), + ), + ); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index de7e23603298..381b1788cf05 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,12 +1,13 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; -import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; +import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; +import { type ComponentProps, useEffect, useMemo } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { useEnvironmentScope } from "~/state/session"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; @@ -85,7 +86,7 @@ export function ElectronBrowserHost() { {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( - ); } + +function AuthorizedBrowserWebview(props: ComponentProps) { + const canOperatePreview = useEnvironmentScope( + props.threadRef.environmentId, + AuthPreviewOperateScope, + ); + return canOperatePreview ? : null; +} diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..0d5059eef32c 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,10 +1,11 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; import { readLocalApi } from "~/localApi"; import { previewEnvironment } from "~/state/preview"; +import { readEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { @@ -43,7 +44,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url, event: options.event ?? NO_MODIFIER, preference: await resolveBrowserLinkTargetPreference(), - canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), + canOpenInApp: + targetThreadRef != null && + readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) && + canOpenLinksInApp(true), }); if (target === "app" && targetThreadRef) { const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ce06f349cf4f..0714062101c4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,12 +22,13 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; -import type { - AssetResource, - EnvironmentId, - ScopedThreadRef, - ServerProviderSkill, - ThreadLinkedPullRequest, +import { + AuthPreviewOperateScope, + type AssetResource, + type EnvironmentId, + type ScopedThreadRef, + type ServerProviderSkill, + type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -148,7 +149,7 @@ import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; -import { readEnvironmentScope, usePreparedConnection } from "../state/session"; +import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; @@ -1994,6 +1995,7 @@ function useChatMarkdownState({ reportFailure: false, }); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; + const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canUseMarkdownFileShellActions( environmentId, @@ -2178,12 +2180,12 @@ function useChatMarkdownState({ ); const openExternalLinkInPreview = useCallback( (url: string) => { - if (!threadRef) { + if (!threadRef || !canOperatePreview) { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ - message: "Thread context is unavailable.", + message: "Preview access is unavailable for this client.", }), ), ), @@ -2194,11 +2196,11 @@ function useChatMarkdownState({ return result; }); }, - [openPreview, threadRef], + [canOperatePreview, openPreview, threadRef], ); const openMarkdownFileInPreview = useCallback( (path: string) => { - if (!threadRef || preparedConnection._tag === "None") { + if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") { return Promise.resolve( AsyncResult.failure( Cause.fail( @@ -2218,7 +2220,7 @@ function useChatMarkdownState({ openPreview, }); }, - [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], + [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { @@ -2336,6 +2338,7 @@ function useChatMarkdownState({ revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && + canOperatePreview && isPreviewSupportedInRuntime() && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) @@ -2347,6 +2350,7 @@ function useChatMarkdownState({ }, [ canUseShellActions, + canOperatePreview, fileLinkParentSuffixByPath, openFileInPanel, openInPreferredEditor, @@ -2362,6 +2366,7 @@ function useChatMarkdownState({ const componentState = useMemo( () => ({ + canOperatePreview, cwd, diffThemeName, environmentId, @@ -2388,6 +2393,7 @@ function useChatMarkdownState({ updateThreadPullRequestLink, }), [ + canOperatePreview, cwd, diffThemeName, environmentId, @@ -2511,6 +2517,7 @@ const CHAT_MARKDOWN_COMPONENTS = { }, a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { const { + canOperatePreview, cwd, environmentId, imageBaseDir, @@ -2576,7 +2583,8 @@ const CHAT_MARKDOWN_COMPONENTS = { }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const canOpenInPreview = + canOperatePreview && Boolean(threadRef) && isPreviewSupportedInRuntime(); const linkChildren = {children}; const link = ( { - if (!activeThreadRef) return; + if (!activeThreadRef || !canOperatePreview) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview, ...(profileId === undefined ? {} : { profileId }), }); }, - [activeThreadRef, openPreview], + [activeThreadRef, canOperatePreview, openPreview], ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; @@ -4067,13 +4076,20 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().close(activeThreadRef); return; } + if (!canOperatePreview) return; const activeTabId = activePreviewState.activeTabId; if (activeTabId) { useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + canOperatePreview, + createBrowserSurface, + previewPanelOpen, + ]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { setMaximizedRightPanelThreadKey(null); @@ -4230,7 +4246,7 @@ export default function ChatView(props: ChatViewProps) { (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; for (const surface of surfaces) { - if (surface.kind === "preview" && surface.resourceId) { + if (canOperatePreview && surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ closePreview, snapshot: activePreviewState.sessions[surface.resourceId] ?? null, @@ -4252,6 +4268,7 @@ export default function ChatView(props: ChatViewProps) { [ activeThreadRef, activePreviewState.sessions, + canOperatePreview, closePreview, closeTerminalMutation, storeCloseTerminal, @@ -8138,7 +8155,10 @@ export default function ChatView(props: ChatViewProps) { - {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( + {canOperatePreview && + activeThreadRef && + activePreviewMiniPlayer && + previewMiniPlayerVisible ? ( ) => { const port = discoveredPorts[0]; - if (!port) return; + if (!port || !canOperatePreview) return; event.preventDefault(); event.stopPropagation(); navigateToThread(threadRef); @@ -455,7 +458,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); })(); }, - [discoveredPorts, navigateToThread, openPreview, threadRef], + [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -777,7 +780,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
- {discoveredPorts.length > 0 && ( + {canOperatePreview && discoveredPorts.length > 0 && ( settings.wordWrap); + const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); @@ -1047,6 +1050,7 @@ export default function FilePreviewPanel({ ? setRenderMarkdownPreferred : setRenderBrowserFilePreferred; const canOpenInBrowser = + canOperatePreview && relativePath !== null && attachment === undefined && !isVideo && @@ -1087,7 +1091,7 @@ export default function FilePreviewPanel({ }; const handleOpenInBrowser = useCallback(() => { - if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return; + if (!canReadFiles || !canOperatePreview || !absolutePath || !environmentHttpBaseUrl) return; void (async () => { const result = await openFileInPreview({ threadRef, @@ -1112,6 +1116,7 @@ export default function FilePreviewPanel({ }, [ absolutePath, canReadFiles, + canOperatePreview, createAssetUrl, cwd, environmentHttpBaseUrl, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b31640906e..acc3d4fbb46f 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -3,6 +3,7 @@ import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { + AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT, PREVIEW_AUTOMATION_OPERATIONS, type EnvironmentId, @@ -47,6 +48,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentScope } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -271,7 +273,7 @@ export function PreviewAutomationHosts() { * lets the subscription runtime own reconnects for every saved target. */} {environments.map((environment) => ( - @@ -280,6 +282,11 @@ export function PreviewAutomationHosts() { ); } +function AuthorizedPreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { + const canOperatePreview = useEnvironmentScope(props.environmentId, AuthPreviewOperateScope); + return canOperatePreview ? : null; +} + function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { const { environmentId } = props; const registry = useContext(RegistryContext); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index eb3912fb5b67..6e4a1ed229a5 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -1,9 +1,10 @@ "use client"; -import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { AuthPreviewOperateScope, type PreviewAnnotationPayload, type ScopedThreadRef } from "@t3tools/contracts"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useEnvironmentScope } from "~/state/session"; import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; import { PreviewView } from "./PreviewView"; @@ -28,12 +29,15 @@ export function PreviewPanel({ visible, onSendAnnotation, }: Props) { - if (!isPreviewSupportedInRuntime()) { + const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + if (!canOperatePreview || !isPreviewSupportedInRuntime()) { return (

- Preview is only available in the T3 Code desktop app. + {canOperatePreview + ? "Preview is only available in the T3 Code desktop app." + : "Pair this client again with preview access to control browser previews."}

diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c0c093ceb9f3..c7151ccba74c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -19,6 +19,7 @@ import { AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, AuthSourceControlWriteScope, @@ -218,6 +219,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Maintain environment", description: "Update the server and control environment processes.", }, + { + scope: AuthPreviewOperateScope, + title: "Control previews", + description: "Open browser previews and host browser automation.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..7cfc7e9a997f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,3 +1,4 @@ +import { AuthPreviewOperateScope } from "@t3tools/contracts"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; @@ -7,6 +8,7 @@ import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; +import { useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; @@ -27,6 +29,10 @@ function ChatRouteGlobalShortcuts() { const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); + const canOperatePreview = useEnvironmentScope( + routeThreadRef?.environmentId ?? null, + AuthPreviewOperateScope, + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const legacySidebarEnabled = useLegacySidebarEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -111,7 +117,7 @@ function ChatRouteGlobalShortcuts() { if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); - if (!routeThreadRef) return; + if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return; if (!isPreviewSupportedInRuntime()) { toastManager.add( stackedThreadToast({ @@ -136,6 +142,7 @@ function ChatRouteGlobalShortcuts() { command === "preview.zoomOut" || command === "preview.resetZoom" ) { + if (!canOperatePreview) return; event.preventDefault(); event.stopPropagation(); const action = @@ -160,6 +167,7 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + canOperatePreview, handleNewThread, keybindings, defaultProjectRef, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 90156f2d1570..a5385e9cf525 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -83,6 +83,7 @@ export const AuthOrchestrationOperateScope = "orchestration:operate" as const; export const AuthSettingsWriteScope = "settings:write" as const; export const AuthProvidersManageScope = "providers:manage" as const; export const AuthEnvironmentMaintainScope = "environment:maintain" as const; +export const AuthPreviewOperateScope = "preview:operate" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthSourceControlWriteScope = "source-control:write" as const; export const AuthFilesystemReadScope = "filesystem:read" as const; @@ -99,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthPreviewOperateScope, AuthTerminalOperateScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, @@ -126,6 +128,7 @@ export const AuthStandardClientScopes = [ AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthPreviewOperateScope, AuthTerminalOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, From 37160f7cf99a6760046ddbddb78fc0a2dbb73bb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:57:58 -0700 Subject: [PATCH 2/7] fix(web): share the environment scope hook after integration --- apps/web/src/components/ChatView.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 74817c6f7c41..f170ed342208 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -421,11 +421,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { - readEnvironmentScope, - readPreparedConnection, - useEnvironmentScope, -} from "../state/session"; +import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; From 842f2c5de2791da6be5b7ebb1bcae521018b9770 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:00:09 -0700 Subject: [PATCH 3/7] fix(auth): repair preview integration types --- apps/server/src/server.test.ts | 2 +- apps/web/src/components/files/FilePreviewPanel.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6a51b033faf1..fa472f4fcd4a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5682,7 +5682,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }, }); - const threadId = ThreadId.makeUnsafe("preview-scope-thread"); + const threadId = ThreadId.make("preview-scope-thread"); const host = { clientId: "preview-scope-host", environmentId: testEnvironmentDescriptor.environmentId, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 675f09b7c84d..7912dbe33519 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -25,7 +25,6 @@ import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useEnvironmentScope } from "~/state/session"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; From 2937aa5e83aaaafb1d70575de305415640f487d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:06:17 -0700 Subject: [PATCH 4/7] style(web): format preview panel import --- apps/web/src/components/preview/PreviewPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index 6e4a1ed229a5..9c19ef2b81d0 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -1,6 +1,10 @@ "use client"; -import { AuthPreviewOperateScope, type PreviewAnnotationPayload, type ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthPreviewOperateScope, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; From 3808be015d383a669580d3c235192bc38c6367d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:18:16 -0700 Subject: [PATCH 5/7] test(web): provide explicit scopes in Markdown fixtures --- apps/web/src/components/ChatMarkdown.test.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index f4551a93088e..30edacd3811f 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -35,10 +35,19 @@ vi.mock("./ui/tooltip", async () => { }); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - usePreparedConnection: () => ({ _tag: "Loading" }), -})); +vi.mock("../state/session", async (importOriginal) => { + const actual = await importOriginal(); + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const grantedScopes = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && grantedScopes.has(scope); + return { + ...actual, + useEnvironmentScope: hasScope, + readEnvironmentScope: hasScope, + usePreparedConnection: () => ({ _tag: "Loading" }), + }; +}); vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], From 61fd7c5131fc2cb493aaf90d1aed8565920752eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:46:19 -0700 Subject: [PATCH 6/7] test(web): grant scopes in workspace image fixtures --- .../ChatMarkdown.workspace-images.test.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39be0eedafe2..2147dfe618f3 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -20,10 +20,19 @@ vi.mock("../assets/assetUrls", () => ({ vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - usePreparedConnection: () => ({ _tag: "Loading" }), -})); +vi.mock("../state/session", async (importOriginal) => { + const actual = await importOriginal(); + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const grantedScopes = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && grantedScopes.has(scope); + return { + ...actual, + useEnvironmentScope: hasScope, + readEnvironmentScope: hasScope, + usePreparedConnection: () => ({ _tag: "Loading" }), + }; +}); vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], From 02a6f15691bfc23da8aa97f22c111d595be0bdc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:13:41 -0700 Subject: [PATCH 7/7] fix(web): consume unavailable preview shortcuts --- apps/web/src/routes/_chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 7cfc7e9a997f..8a63c525d809 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -142,9 +142,9 @@ function ChatRouteGlobalShortcuts() { command === "preview.zoomOut" || command === "preview.resetZoom" ) { - if (!canOperatePreview) return; event.preventDefault(); event.stopPropagation(); + if (!canOperatePreview) return; const action = command === "preview.refresh" ? "refresh"