From b37f9c437b7d8bfea3b721867e5806bbda2be9fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:49:54 -0700 Subject: [PATCH 01/29] feat(auth): separate filesystem access scopes --- .../features/files/ThreadFilesRouteScreen.tsx | 18 +- .../features/files/preload-workspace-file.ts | 3 + .../files/thread-file-navigator-pane.tsx | 9 +- .../features/projects/AddProjectScreen.tsx | 8 +- .../src/features/review/useReviewSections.ts | 5 +- .../threads/new-task-flow-provider.tsx | 5 +- apps/mobile/src/state/assets.ts | 11 +- apps/mobile/src/state/queries.ts | 7 +- apps/server/src/auth/RpcAuthorization.ts | 19 +- apps/server/src/auth/http.ts | 26 +-- apps/server/src/cli/authScopes.ts | 6 +- apps/server/src/server.test.ts | 174 +++++++++++++++++- apps/server/src/ws.ts | 7 + apps/web/src/assets/assetUrls.ts | 40 ++-- apps/web/src/components/ChatMarkdown.tsx | 21 ++- apps/web/src/components/CommandPalette.tsx | 7 +- apps/web/src/components/DiffPanel.tsx | 9 +- .../src/components/chat/ProposedPlanCard.tsx | 11 +- .../src/components/files/FilePreviewPanel.tsx | 33 +++- .../files/fileSaveCoordinator.test.ts | 24 +++ .../components/files/fileSaveCoordinator.ts | 4 + .../files/projectFilesQueryState.test.ts | 44 +++++ .../files/projectFilesQueryState.test.tsx | 26 +++ .../files/projectFilesQueryState.ts | 28 ++- .../files/useFileSaveCoordinator.test.tsx | 66 ++++++- .../files/useFileSaveCoordinator.ts | 23 ++- .../settings/ConnectionsSettings.tsx | 31 ++-- apps/web/src/environments/primary/auth.ts | 3 +- apps/web/src/state/queries.ts | 7 +- docs/user/remote-access.md | 4 + packages/contracts/src/auth.test.ts | 12 ++ packages/contracts/src/auth.ts | 17 +- 32 files changed, 594 insertions(+), 114 deletions(-) create mode 100644 packages/contracts/src/auth.test.ts diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca8fd9c61415..b041fe205220 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "../../state/session"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; @@ -259,13 +261,13 @@ function useThreadFilesWorkspace(params: { }; } -function FilesUnavailable() { +function FilesUnavailable({ detail = "This thread does not have an active workspace path." }: { detail?: string }) { return ( ); @@ -313,8 +315,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { props.route.params, ); const revealedInspectorRef = useRef(false); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); const entriesQuery = useEnvironmentQuery( - environmentId !== null && cwd !== null && !fileInspector.supported + canReadFiles && environmentId !== null && cwd !== null && !fileInspector.supported ? projectEnvironment.listEntries({ environmentId, input: { cwd }, @@ -406,6 +409,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { return ; } + if (!canReadFiles) return ; if (cwd === null) { return ; } @@ -517,7 +521,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} ; } + if (!canReadFiles) return ; if (cwd === null) { return ; } @@ -926,7 +932,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { mediaSource={mediaSource} resolveVideoUri={assetPreview.refresh} fileContents={fileData?.contents ?? null} - fileError={fileQuery.error} + fileError={canReadFiles ? fileQuery.error : "This connection cannot read host files."} initialLine={targetLine} relativePath={relativePath} threadId={threadId} diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index a91e4f84b0d0..4ae4f507caa0 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { readEnvironmentScope } from "../../state/session"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -30,6 +32,7 @@ export function preloadWorkspaceFileContents(input: { readonly theme: ReviewDiffTheme; }): void { if ( + !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) || isWorkspaceBrowserPreviewPath(input.relativePath) || isWorkspaceImagePreviewPath(input.relativePath) || isVideoPreviewFile(input.relativePath) diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 33b99dd8e8ce..12f1d247409c 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "../../state/session"; import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; @@ -33,11 +35,12 @@ export function ThreadFileNavigatorPane(props: { const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + const canReadFiles = useEnvironmentScope(props.environmentId, AuthFilesystemReadScope); const entriesQuery = useEnvironmentQuery( - projectEnvironment.listEntries({ + canReadFiles ? projectEnvironment.listEntries({ environmentId: props.environmentId, input: { cwd: props.cwd }, - }), + }) : null, ); const entriesData = entriesQuery.data as ProjectListEntriesResult | null; const handlePreviewFile = useCallback( @@ -71,7 +74,7 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( { - if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { + if (environment && readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, input: { partialPath: selectedDirectoryPath }, @@ -785,8 +787,9 @@ function FolderBrowser(props: { () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); + const canReadFiles = useEnvironmentScope(props.environment.environmentId, AuthFilesystemReadScope); const browseState = useEnvironmentQuery( - browseInput === null + !canReadFiles || browseInput === null ? null : filesystemEnvironment.browse({ environmentId: props.environment.environmentId, @@ -808,6 +811,7 @@ function FolderBrowser(props: { return ( <> Browse folders + {!canReadFiles ? : null} {browseState.error ? : null} {browseState.isPending && browseState.data === null ? ( diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 87325490990c..a71895c05ac5 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "../../state/session"; import { useCallback, useEffect, useMemo } from "react"; import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; @@ -30,10 +32,11 @@ export function useReviewSections(input: { }) { const { environmentId, reviewCache, threadId } = input; const enabled = input.enabled ?? true; + const canReadFiles = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const diffPreview = useEnvironmentQuery( - enabled && environmentId !== undefined && selectedThreadCwd !== null + canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null ? reviewEnvironment.diffPreview({ environmentId, input: { cwd: selectedThreadCwd }, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6d87f284ebda..ee6aae26c261 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "../../state/session"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { @@ -378,8 +380,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's // checked-in t3.json, then the server's configured default. + const canReadFiles = useEnvironmentScope(selectedProject?.environmentId ?? null, AuthFilesystemReadScope); const t3ProjectFileQuery = useEnvironmentQuery( - selectedProject !== null && selectedProject.workspaceRoot !== "" + canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ environmentId: selectedProject.environmentId, input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..4b770420059f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,3 +1,4 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentConnectionPhase, @@ -16,7 +17,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { usePreparedConnection, useEnvironmentScope, readEnvironmentScope } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; @@ -41,14 +42,16 @@ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const canReadResource = canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); const connectionPhase = useConnectionPhase(environmentId); const result = useAtomValue( - environmentId === null || resource === null + !canReadResource || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - const shared = assetUrlStateFromResult( + const shared = !canReadResource ? { _tag: "Failure" as const } : assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, ); @@ -82,6 +85,8 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; + if ((resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(environmentId, AuthFilesystemReadScope)) return null; const state = assetUrlStateFromResult( await createUrl({ environmentId, input: { resource } }), httpBaseUrl, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 0c0da1f847d5..90cf975d62bc 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "./session"; import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -253,8 +255,9 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { [target.cwd, target.environmentId, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); const result = useEnvironmentQuery( - debouncedTarget.environmentId !== null && + canReadFiles && debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && debouncedTarget.query.length > 0 ? projectEnvironment.searchEntries({ @@ -270,7 +273,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], - error: result.error, + error: canReadFiles ? result.error : "This connection cannot search host files.", isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, refresh: result.refresh, }; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 263cf8bf840a..2dfe6d595042 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -3,11 +3,12 @@ import { AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthFilesystemReadScope, + AuthFilesystemWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, AuthRelayWriteScope, - AuthReviewWriteScope, AuthSourceControlWriteScope, AuthTerminalOperateScope, ORCHESTRATION_WS_METHODS, @@ -96,13 +97,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, - [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, - [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, - [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, - [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, - [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, + [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, + [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, + [WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope, + [WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, - [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.filesystemBrowse]: AuthFilesystemReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, @@ -122,8 +123,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, - [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, - [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, + [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, + [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index be9c65ab98f6..1d2ca5dfe846 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -2,16 +2,7 @@ import { AuthAccessReadScope, AuthAccessWriteScope, AuthStandardClientScopes, - AuthSettingsWriteScope, - AuthProvidersManageScope, - AuthEnvironmentMaintainScope, - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - AuthRelayReadScope, - AuthRelayWriteScope, - AuthReviewWriteScope, - AuthSourceControlWriteScope, - AuthTerminalOperateScope, + AuthGrantScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, EnvironmentHttpApi, @@ -316,20 +307,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( ? undefined : parseAllowedOAuthScope({ value: args.payload.scope, - allowedScopes: new Set([ - AuthOrchestrationReadScope, - AuthOrchestrationOperateScope, - AuthSettingsWriteScope, - AuthProvidersManageScope, - AuthEnvironmentMaintainScope, - AuthTerminalOperateScope, - AuthReviewWriteScope, - AuthSourceControlWriteScope, - AuthAccessReadScope, - AuthAccessWriteScope, - AuthRelayReadScope, - AuthRelayWriteScope, - ]), + allowedScopes: new Set(AuthGrantScope.literals), }); if (requestedScopes === null) { return yield* failEnvironmentInvalidRequest("invalid_scope"); diff --git a/apps/server/src/cli/authScopes.ts b/apps/server/src/cli/authScopes.ts index 36a54356cabd..abf0311e2927 100644 --- a/apps/server/src/cli/authScopes.ts +++ b/apps/server/src/cli/authScopes.ts @@ -1,8 +1,8 @@ -import { AuthEnvironmentScope } from "@t3tools/contracts"; +import { AuthGrantScope } from "@t3tools/contracts"; import { Flag } from "effect/unstable/cli"; -export const authScopesFlag = (defaults: ReadonlyArray) => - Flag.choice("scope", AuthEnvironmentScope.literals).pipe( +export const authScopesFlag = (defaults: ReadonlyArray) => + Flag.choice("scope", AuthGrantScope.literals).pipe( Flag.withDescription( `Authorization scope to grant. Repeat for multiple scopes; replaces the default set: ${defaults.join(", ")}.`, ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 77d46a5c7e0e..d2c96a82cfd6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1349,6 +1349,16 @@ const exchangeAccessToken = ( }; }); +const getScopedWsUrl = Effect.fn("test.getScopedWsUrl")(function* (scope: string) { + 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* ticketResponse.json) as { readonly ticket: string }; + return `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket.ticket)}`; +}); + const makeDpopProof = (input: { readonly method: string; readonly url: string; @@ -2470,7 +2480,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headers: { "user-agent": "undici", }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", clientMetadata: { label: "T3 Code Mobile", deviceType: "mobile", @@ -2539,7 +2550,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { subject_token: credential.credential, subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", requested_token_type: "urn:ietf:params:oauth:token-type:access_token", - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }).toString(), }); const token = yield* responseJsonEffect<{ @@ -2604,7 +2616,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const exchange = yield* exchangeAccessToken(credential.credential, { headers: { dpop: dpop.proof }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }); assert.equal(exchange.response.status, 401); @@ -2651,13 +2664,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headers: { dpop: dpop.proof, }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }); const replayBootstrap = yield* exchangeAccessToken(secondCredential.credential, { headers: { dpop: dpop.proof, }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }); assert.equal(firstBootstrap.response.status, 200); @@ -2697,7 +2712,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dpop: dpop.proof, "x-forwarded-host": "environment.example.test", }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }); assert.equal(bootstrap.response.status, 200); @@ -2734,7 +2750,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dpop: dpop.proof, "x-forwarded-host": spoofedUrl.host, }, - scope: "orchestration:read orchestration:operate terminal:operate review:write", + scope: + "orchestration:read orchestration:operate terminal:operate filesystem:read filesystem:write", }); assert.equal(bootstrap.response.status, 401); @@ -6904,6 +6921,149 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires filesystem scopes for file reads and writes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-filesystem-scopes-" }); + const filePath = path.join(cwd, "scope.txt"); + yield* fs.writeFileString(filePath, "original"); + yield* buildAppUnderTest(); + + const operatorUrl = yield* getScopedWsUrl("orchestration:read orchestration:operate"); + yield* Effect.scoped( + withWsRpcClient(operatorUrl, (client) => + Effect.gen(function* () { + const read = yield* client[WS_METHODS.projectsReadFile]({ + cwd, + relativePath: "scope.txt", + }).pipe(Effect.flip); + const write = yield* client[WS_METHODS.projectsWriteFile]({ + cwd, + relativePath: "scope.txt", + contents: "denied", + }).pipe(Effect.flip); + assert.equal(read._tag, "EnvironmentAuthorizationError"); + assert.equal(write._tag, "EnvironmentAuthorizationError"); + }), + ), + ); + assert.equal(yield* fs.readFileString(filePath), "original"); + + const readerUrl = yield* getScopedWsUrl("filesystem:read"); + yield* Effect.scoped( + withWsRpcClient(readerUrl, (client) => + Effect.gen(function* () { + const read = yield* client[WS_METHODS.projectsReadFile]({ + cwd, + relativePath: "scope.txt", + }); + assert.equal(read.contents, "original"); + const write = yield* client[WS_METHODS.projectsWriteFile]({ + cwd, + relativePath: "scope.txt", + contents: "denied", + }).pipe(Effect.flip); + assert.equal(write._tag, "EnvironmentAuthorizationError"); + }), + ), + ); + assert.equal(yield* fs.readFileString(filePath), "original"); + + const writerUrl = yield* getScopedWsUrl("filesystem:write"); + yield* Effect.scoped( + withWsRpcClient(writerUrl, (client) => + client[WS_METHODS.projectsWriteFile]({ + cwd, + relativePath: "scope.txt", + contents: "allowed", + }), + ), + ); + assert.equal(yield* fs.readFileString(filePath), "allowed"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("requires filesystem read for host asset URLs while preserving attachment access", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-asset-scopes-" }); + const filePath = path.join(cwd, "report.html"); + yield* fs.writeFileString(filePath, "

host file

"); + const project = { ...makeDefaultOrchestrationReadModel().projects[0]!, workspaceRoot: cwd }; + const config = yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed(Option.some(makeDefaultOrchestrationThreadShell())), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }, + }, + }); + yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); + const attachmentId = "pending-00000000-0000-4000-8000-000000000001-pdf"; + yield* fs.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.pdf`), + "attachment", + ); + + const readerUrl = yield* getScopedWsUrl("orchestration:read"); + yield* Effect.scoped( + withWsRpcClient(readerUrl, (client) => + Effect.gen(function* () { + for (const tag of ["workspace-file", "media-file"] as const) { + const denied = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: tag, threadId: defaultThreadId, path: filePath }, + }).pipe(Effect.flip); + assert.equal(denied._tag, "EnvironmentAuthorizationError"); + if (denied._tag === "EnvironmentAuthorizationError") + assert.equal(denied.requiredScope, "filesystem:read"); + } + const attachment = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "report.pdf", + mimeType: "application/pdf", + }, + }); + const response = yield* HttpClient.get(attachment.relativeUrl); + assert.equal(response.status, 200); + assert.equal(yield* response.text, "attachment"); + }), + ), + ); + + const filesystemUrl = yield* getScopedWsUrl("filesystem:read"); + yield* Effect.scoped( + withWsRpcClient(filesystemUrl, (client) => + Effect.gen(function* () { + for (const tag of ["workspace-file", "media-file"] as const) { + const asset = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: tag, threadId: defaultThreadId, path: filePath }, + }); + const response = yield* HttpClient.get(asset.relativeUrl); + assert.equal(response.status, 200); + assert.equal(yield* response.text, "

host file

"); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not issue the retired review scope", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const retired = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "review:write", + }); + assert.equal(retired.response.status, 400); + assert.equal(retired.body.reason, "invalid_scope"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f432c2cc13a9..4a8922d23526 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,7 +14,9 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + AuthFilesystemReadScope, AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, AuthSessionId, ClientConnectionMethod, ClientDeviceType, @@ -2439,6 +2441,11 @@ const makeWsRpcLayer = ( }); }), { "rpc.aggregate": "workspace" }, + [ + input.resource._tag === "workspace-file" || input.resource._tag === "media-file" + ? AuthFilesystemReadScope + : AuthOrchestrationReadScope, + ], ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 84ff979e4e89..c56f4d094404 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,3 +1,4 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { type AssetUrlState, @@ -11,7 +12,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; -import { usePreparedConnection } from "~/state/session"; +import { usePreparedConnection, useEnvironmentScope, readEnvironmentScope } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; @@ -20,12 +21,15 @@ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const canReadResource = canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( - environmentId === null || resource === null + !canReadResource || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); + if (!canReadResource) return { _tag: "Failure" }; return assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, @@ -50,6 +54,8 @@ export function useAssetUrlRefresh( }); return useCallback(async () => { if (environmentId === null || resource === null) return; + if ((resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(environmentId, AuthFilesystemReadScope)) return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); @@ -60,21 +66,31 @@ export function useAssetUrls( resources: ReadonlyArray, ): ReadonlyArray { const preparedConnection = usePreparedConnection(environmentId); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const allowedResources = useMemo( + () => canReadFiles + ? resources + : resources.filter((resource) => resource._tag !== "workspace-file" && resource._tag !== "media-file"), + [canReadFiles, resources], + ); const results = useAtomValue( assetEnvironment.createUrls({ environmentId, - resources, + resources: allowedResources, }), ); return useMemo( - () => - preparedConnection._tag === "None" - ? resources.map(() => null) - : results.map((result) => - AsyncResult.isSuccess(result) - ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) - : null, - ), - [preparedConnection, resources, results], + () => { + if (preparedConnection._tag === "None") return resources.map(() => null); + let resultIndex = 0; + return resources.map((resource) => { + if (!canReadFiles && (resource._tag === "workspace-file" || resource._tag === "media-file")) return null; + const result = results[resultIndex++]; + return result && AsyncResult.isSuccess(result) + ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) + : null; + }); + }, + [canReadFiles, preparedConnection, resources, results], ); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 33b2b086d8fc..6bc7dad311cf 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,8 @@ -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -2149,10 +2153,21 @@ function useChatMarkdownState({ mediaRequestId.current += 1; }; }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); - const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + const loadAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); + const createAssetUrl = useCallback((input) => { + const resource = input.input.resource; + if ((resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope)) { + return Promise.resolve(AsyncResult.failure(Cause.fail(new EnvironmentAuthorizationError({ + message: "This connection cannot read host files.", + requiredScope: AuthFilesystemReadScope, + })))); + } + return loadAssetUrl(input); + }, [loadAssetUrl]); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); @@ -2409,7 +2424,7 @@ function useChatMarkdownState({ ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { - if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + if (!cwd || environmentId === null || !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { return null; } const result = await searchProjectEntries({ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0eaf4bf02570..faf2338fc3a8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope, readEnvironmentScope } from "~/state/session"; "use client"; import { @@ -991,8 +993,9 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; + const canBrowseFiles = useEnvironmentScope(browseEnvironmentId, AuthFilesystemReadScope); const browseQuery = useEnvironmentQuery( - isBrowsing && + canBrowseFiles && isBrowsing && browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject @@ -1033,7 +1036,7 @@ function OpenCommandPaletteDialog(props: { const environment = environments.find( (candidate) => candidate.environmentId === environmentId, ); - if (!canPreloadBrowsePath(environment?.connection.phase)) { + if (!readEnvironmentScope(environmentId, AuthFilesystemReadScope) || !canPreloadBrowsePath(environment?.connection.phase)) { return; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 87f5f1ba56f8..ca80c007906c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "~/state/session"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -138,6 +140,7 @@ export default function DiffPanel({ }); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); + const canReadFiles = useEnvironmentScope(activeThread?.environmentId ?? null, AuthFilesystemReadScope); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useProject( activeThread && activeProjectId @@ -259,7 +262,7 @@ export default function DiffPanel({ { enabled: isGitRepo && selectedTurn !== undefined }, ); const primaryBranchDiffPreview = useEnvironmentQuery( - selectedTurnId === null && activeThread && activeCwd + canReadFiles && selectedTurnId === null && activeThread && activeCwd ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, input: { @@ -276,7 +279,7 @@ export default function DiffPanel({ serverConfig?.cwd !== undefined && serverConfig.cwd !== activeCwd; const fallbackBranchDiffPreview = useEnvironmentQuery( - shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig + canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, input: { @@ -320,6 +323,7 @@ export default function DiffPanel({ return undefined; } + if (!canReadFiles) return undefined; return createGitDiffFileContentsLoader(getDiffFileContents, { environmentId: activeThread.environmentId, cwd: preview.cwd, @@ -332,6 +336,7 @@ export default function DiffPanel({ activeThread, branchDiffPreview.data, getDiffFileContents, + canReadFiles, selectedGitSource, selectedTurnId, ]); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 9746857a8cac..a19ec1b055bc 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -3,7 +3,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { AuthFilesystemWriteScope, type EnvironmentId, type ScopedThreadRef } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -32,6 +32,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { projectEnvironment } from "~/state/projects"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useAtomCommand } from "~/state/use-atom-command"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, @@ -47,6 +48,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ workspaceRoot: string | undefined; }) { const [expanded, setExpanded] = useState(false); + const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); @@ -85,6 +87,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const openSaveDialog = () => { + if (!canWriteFiles) return; if (!workspaceRoot) { toastManager.add( stackedThreadToast({ @@ -101,7 +104,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const handleSaveToWorkspace = () => { const relativePath = savePath.trim(); - if (!workspaceRoot) { + if (!workspaceRoot || !readEnvironmentScope(environmentId, AuthFilesystemWriteScope)) { return; } if (!relativePath) { @@ -163,7 +166,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ {isCopied ? "Copied!" : "Copy to clipboard"} Download as markdown - + Save to workspace @@ -244,7 +247,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 33d4d9a4b6cf..6e32b43eeca0 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -5,6 +5,7 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; +import { AuthFilesystemReadScope, AuthFilesystemWriteScope } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -47,6 +48,7 @@ import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; @@ -988,6 +990,8 @@ export default function FilePreviewPanel({ // A file outside the workspace (an absolute path) is shown, never edited. const isHostFile = attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const file = useProjectFileQuery( environmentId, cwd, @@ -1072,7 +1076,7 @@ export default function FilePreviewPanel({ }; const handleOpenInBrowser = useCallback(() => { - if (!absolutePath || !environmentHttpBaseUrl) return; + if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return; void (async () => { const result = await openFileInPreview({ threadRef, @@ -1094,7 +1098,23 @@ export default function FilePreviewPanel({ }), ); })(); - }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); + }, [ + absolutePath, + canReadFiles, + createAssetUrl, + cwd, + environmentHttpBaseUrl, + openPreview, + threadRef, + ]); + + if (attachment === undefined && !canReadFiles) { + return ( +
+ This connection cannot read host files. +
+ ); + } return (
@@ -1212,6 +1232,11 @@ export default function FilePreviewPanel({ ) : null}
) : null} + {relativePath && !attachment && !isHostFile && !canWriteFiles ? ( +
+ Read-only connection. Unsaved edits are kept until write access returns. +
+ ) : null} {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? (
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. @@ -1276,10 +1301,10 @@ export default function FilePreviewPanel({ relativePath={relativePath} threadRef={threadRef} contents={file.data.contents} - readOnly={isHostFile} + readOnly={isHostFile || !canWriteFiles} onPendingChange={onPendingChange} /> - ) : file.data.truncated || isHostFile ? ( + ) : file.data.truncated || isHostFile || !canWriteFiles ? ( { expect(persist).toHaveBeenCalledWith("unsaved"); }); + for (const closeEditor of [false, true]) { + it(`keeps an edit pending without saving after write permission is removed${closeEditor ? " when closing" : ""}`, async () => { + vi.useFakeTimers(); + let canWrite = true; + const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const coordinator = new FileSaveCoordinator({ + debounceMs: 500, + canPersist: () => canWrite, + persist, + onPendingChange, + onConfirmed: vi.fn(), + }); + + coordinator.change("unsaved"); + canWrite = false; + if (closeEditor) coordinator.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange).toHaveBeenLastCalledWith(true); + }); + } + it("flushes an edit made while a write was in flight when the editor closes", async () => { vi.useFakeTimers(); const inFlight = deferred(); diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index e9d3f11e9cd7..09370296e5bd 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -2,6 +2,7 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; export interface FileSaveCoordinatorOptions { readonly debounceMs: number; + readonly canPersist?: () => boolean; readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; readonly onConfirmed: (contents: string) => void; @@ -49,6 +50,9 @@ export class FileSaveCoordinator { private async persistLatest(): Promise { if (this.saving || this.latestRevision === this.confirmedRevision) return; + if (this.options.canPersist?.() === false) { + return; + } this.saving = true; const contents = this.latestContents; diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index 6486e016f007..1f43ab917827 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -1,11 +1,14 @@ import type { ProjectReadFileResult } from "@t3tools/contracts"; import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { clearProjectFileQueryData, confirmProjectFileQueryData, getOptimisticProjectFileQueryData, + getUnsavedProjectFileQueryData, resolveProjectFileQueryData, setProjectFileQueryData, } from "./projectFilesQueryState"; @@ -15,9 +18,50 @@ const environmentId = EnvironmentId.make("environment-project-files-query-test") describe("project files queries", () => { afterEach(() => { clearProjectFileQueryData(environmentId, "/repo", "convex.json"); + vi.useRealTimers(); vi.unstubAllGlobals(); }); + it("resumes an unsaved draft after write access returns and the editor reopens", async () => { + vi.stubGlobal("window", {}); + vi.useFakeTimers(); + let canWrite = true; + const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); + const onPendingChange = vi.fn(); + const makeCoordinator = () => + new FileSaveCoordinator({ + debounceMs: 500, + canPersist: () => canWrite, + persist, + onPendingChange, + onConfirmed: (contents) => { + confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents); + }, + }); + const initial = makeCoordinator(); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "unsaved draft"); + initial.change("unsaved draft"); + canWrite = false; + initial.dispose(); + await vi.runAllTimersAsync(); + + expect(persist).not.toHaveBeenCalled(); + expect(onPendingChange).toHaveBeenLastCalledWith(true); + const unsaved = getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json"); + expect(unsaved?.contents).toBe("unsaved draft"); + + canWrite = true; + const reopened = makeCoordinator(); + reopened.change(unsaved!.contents); + await vi.advanceTimersByTimeAsync(500); + + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith("unsaved draft"); + expect(onPendingChange).toHaveBeenLastCalledWith(false); + expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); + reopened.dispose(); + }); + it("keeps the latest optimistic draft when an older write finishes", () => { vi.stubGlobal("window", {}); const initial = { diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 8edf982a030a..a548566ea42f 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -7,6 +7,12 @@ import * as Effect from "effect/Effect"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +const authorizationMocks = vi.hoisted(() => ({ canReadFiles: true })); + +vi.mock("~/state/session", () => ({ + useEnvironmentScope: () => authorizationMocks.canReadFiles, +})); + const projectMocks = vi.hoisted(() => ({ listEntries: vi.fn(), optimisticFile: vi.fn(), @@ -111,12 +117,32 @@ async function flushEffects(): Promise { describe("project query refresh", () => { beforeEach(() => { + authorizationMocks.canReadFiles = true; projectMocks.listEntries.mockReset(); projectMocks.optimisticFile.mockReset(); projectMocks.readFile.mockReset(); reactHooks.reset(); }); + it("does not query or expose optimistic file contents without read permission", () => { + authorizationMocks.canReadFiles = false; + const registry = AtomRegistry.make(); + atomHooks.registry = registry; + projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); + try { + const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(query.data).toBeNull(); + expect(query.error).toBe("This connection cannot read host files."); + const entries = useProjectEntriesQuery(environmentId, "/repo"); + expect(projectMocks.listEntries).not.toHaveBeenCalled(); + expect(entries.data).toBeNull(); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }); + it("replaces an in-flight initial read when a workspace mutation arrives", async () => { const requests: Array>> = []; const readAtom = Atom.make( diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d02ec99605ba..76c239dd9b3f 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,6 +4,7 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; +import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -16,9 +17,13 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; import { useProjectPathSearch } from "~/state/queries"; +import { useEnvironmentScope } from "~/state/session"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; +const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make( + AsyncResult.initial(false), +); const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); @@ -73,6 +78,15 @@ export function getOptimisticProjectFileQueryData( return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null; } +export function getUnsavedProjectFileQueryData( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +): ProjectReadFileResult | null { + const optimistic = appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath)); + return optimistic?.confirmedAgainst === undefined ? (optimistic?.data ?? null) : null; +} + export function confirmProjectFileQueryData( environmentId: EnvironmentId, cwd: string, @@ -129,13 +143,16 @@ export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, ): ProjectQueryState { - const atom = getProjectEntriesQueryAtom(environmentId, cwd); + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const atom = canReadFiles + ? getProjectEntriesQueryAtom(environmentId, cwd) + : EMPTY_PROJECT_ENTRIES_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); return { data: Option.getOrNull(AsyncResult.value(result)), - error: errorMessage(result), + error: canReadFiles ? errorMessage(result) : "This connection cannot read host files.", isPending: result.waiting, refresh, }; @@ -182,11 +199,12 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { + const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); const isMedia = relativePath !== null && (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); const atom = - enabled && !isMedia + canReadFiles && enabled && !isMedia ? getProjectFileQueryAtom(environmentId, cwd, relativePath) : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); @@ -199,8 +217,8 @@ export function useProjectFileQuery( const optimisticFile = relativePath === null ? null : optimisticResult; return { - data: optimisticFile?.data ?? data, - error: errorMessage(result), + data: canReadFiles ? (optimisticFile?.data ?? data) : null, + error: canReadFiles ? errorMessage(result) : "This connection cannot read host files.", isPending: result.waiting, refresh, }; diff --git a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx index 603f97cf1f26..e10914708696 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.test.tsx +++ b/apps/web/src/components/files/useFileSaveCoordinator.test.tsx @@ -4,13 +4,22 @@ import { act, StrictMode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const { writeFile, confirmFile } = vi.hoisted(() => ({ +const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({ writeFile: vi.fn(), confirmFile: vi.fn(), + readScope: vi.fn(), + getUnsavedFile: vi.fn(), })); vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); +vi.mock("~/state/session", () => ({ + readEnvironmentScope: readScope, + useEnvironmentScope: readScope, +})); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile })); -vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile })); +vi.mock("./projectFilesQueryState", () => ({ + confirmProjectFileQueryData: confirmFile, + getUnsavedProjectFileQueryData: getUnsavedFile, +})); import { setMarkdownTaskChecked } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; @@ -54,6 +63,8 @@ beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); confirmFile.mockReset(); + readScope.mockReset().mockReturnValue(true); + getUnsavedFile.mockReset().mockReturnValue(null); onPendingChange.mockReset(); }); @@ -124,6 +135,57 @@ describe("file-save React lifecycle", () => { expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit"); }); + it.each([false, true])( + "keeps edits pending after permission is revoked before a React update (unmount: %s)", + async (unmount) => { + mount(); + changeHandler()("pending edit"); + readScope.mockReturnValue(false); + if (unmount) { + await act(async () => renderer!.unmount()); + renderer = null; + } + await vi.runAllTimersAsync(); + expect(writeFile).not.toHaveBeenCalled(); + expect(confirmFile).not.toHaveBeenCalled(); + expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", true); + }, + ); + + it("resumes an unsaved draft when write permission returns after effect replay", async () => { + readScope.mockReturnValue(false); + getUnsavedFile.mockReturnValue({ contents: "pending draft" }); + mount(); + await vi.runAllTimersAsync(); + expect(writeFile).not.toHaveBeenCalled(); + + readScope.mockReturnValue(true); + act(() => + renderer!.update( + + + , + ), + ); + await vi.advanceTimersByTimeAsync(500); + expect(writeFile).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { cwd: "/workspace", relativePath: "file.txt", contents: "pending draft" }, + }); + expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); + }); + + it("recovers an existing draft once after StrictMode setup replay", async () => { + getUnsavedFile.mockReturnValue({ contents: "reopened draft" }); + mount(); + await vi.runAllTimersAsync(); + expect(writeFile).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { cwd: "/workspace", relativePath: "file.txt", contents: "reopened draft" }, + }); + expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); + }); + it.each([ { relativePath: "other.txt" }, { cwd: "/other-workspace" }, diff --git a/apps/web/src/components/files/useFileSaveCoordinator.ts b/apps/web/src/components/files/useFileSaveCoordinator.ts index 852490cf5be4..a2d7074c361e 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.ts +++ b/apps/web/src/components/files/useFileSaveCoordinator.ts @@ -1,11 +1,15 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { AuthFilesystemWriteScope, type EnvironmentId } from "@t3tools/contracts"; import { createRef, useEffect, useMemo } from "react"; import { projectEnvironment } from "~/state/projects"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; -import { confirmProjectFileQueryData } from "./projectFilesQueryState"; +import { + confirmProjectFileQueryData, + getUnsavedProjectFileQueryData, +} from "./projectFilesQueryState"; const FILE_SAVE_DEBOUNCE_MS = 500; @@ -22,6 +26,7 @@ export function useFileSaveCoordinator({ relativePath, onPendingChange, }: FileSaveOptions): Pick { + const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const writeFile = useAtomCommand(projectEnvironment.writeFile); const session = useMemo(() => { const coordinatorRef = createRef(); @@ -30,6 +35,7 @@ export function useFileSaveCoordinator({ setup: () => { const coordinator = new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, + canPersist: () => readEnvironmentScope(environmentId, AuthFilesystemWriteScope), onPendingChange: (pending) => onPendingChange(relativePath, pending), persist: (nextContents) => writeFile({ @@ -52,5 +58,18 @@ export function useFileSaveCoordinator({ // StrictMode replays effect setup. Retired file sessions stay inert, while the // replay gets a fresh coordinator instead of reusing a disposed one. useEffect(session.setup, [session]); + useEffect(() => { + if (!canWriteFiles) return; + let cancelled = false; + // Replay must retire the first session before recovery queues a draft to flush. + queueMicrotask(() => { + if (cancelled) return; + const unsaved = getUnsavedProjectFileQueryData(environmentId, cwd, relativePath); + if (unsaved) session.change(unsaved.contents); + }); + return () => { + cancelled = true; + }; + }, [canWriteFiles, cwd, environmentId, relativePath, session]); return session; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 82241469446d..96f45a31e95a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -21,12 +21,14 @@ import { AuthOrchestrationReadScope, AuthRelayReadScope, AuthRelayWriteScope, - AuthReviewWriteScope, AuthSourceControlWriteScope, + AuthFilesystemReadScope, + AuthFilesystemWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, type AuthClientSession, type AuthEnvironmentScope, + type AuthGrantScope, type AuthPairingLink, type AuthPairingCredentialResult, type AdvertisedEndpoint, @@ -187,19 +189,19 @@ function formatAccessTimestamp(value: string): string { } const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ - readonly scope: AuthEnvironmentScope; + readonly scope: AuthGrantScope; readonly title: string; readonly description: string; }> = [ { scope: AuthOrchestrationReadScope, title: "View environment", - description: "Read threads, status, diffs, and configuration.", + description: "Read threads, status, checkpoints, and configuration.", }, { scope: AuthOrchestrationOperateScope, title: "Operate tasks", - description: "Start tasks and perform changes in the environment.", + description: "Start, update, and stop tasks.", }, { scope: AuthSettingsWriteScope, @@ -227,9 +229,14 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ description: "Commit, push, manage branches and repositories, and change pull requests.", }, { - scope: AuthReviewWriteScope, - title: "Write reviews", - description: "Create comments while reviewing changes.", + scope: AuthFilesystemReadScope, + title: "Read files", + description: "Browse host files, search workspaces, and inspect local changes.", + }, + { + scope: AuthFilesystemWriteScope, + title: "Write files", + description: "Edit workspace files and save plans to disk.", }, { scope: AuthAccessReadScope, @@ -1057,7 +1064,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio const primaryEnvironmentId = usePrimaryEnvironmentId(); const [dialogOpen, setDialogOpen] = useState(false); const [pairingLabel, setPairingLabel] = useState(""); - const [pairingScopes, setPairingScopes] = useState>([ + const [pairingScopes, setPairingScopes] = useState>([ ...AuthStandardClientScopes, ]); const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope)); @@ -1097,7 +1104,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio } }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); - const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { + const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => { setPairingScopes((current) => checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), ); @@ -1169,9 +1176,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio disabled={isCreatingPairingLink} onClick={() => setPairingScopes( - delegatableScopes.includes(AuthOrchestrationReadScope) - ? [AuthOrchestrationReadScope] - : [], + [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => + delegatableScopes.includes(scope), + ), ) } > diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 7d7c7d9d8fee..90dc14183f21 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -2,6 +2,7 @@ import type { AuthBrowserSessionResult, AuthClientMetadata, AuthEnvironmentScope, + AuthGrantScope, AuthPairingCredentialResult, ServerAuthSessionMethod, AuthSessionId, @@ -363,7 +364,7 @@ export async function submitServerAuthCredential(credential: string): Promise; + readonly scopes?: ReadonlyArray; }): Promise { const trimmedLabel = input?.label?.trim(); try { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..b0b2d12b90b7 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -1,3 +1,5 @@ +import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { useEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffTarget, @@ -270,8 +272,9 @@ export function useProjectPathSearch( [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); + const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); const result = useEnvironmentQuery( - debouncedTarget.environmentId !== null && + canReadFiles && debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && debouncedTarget.query !== null && (allowEmptyQuery || debouncedTarget.query.length > 0) @@ -290,7 +293,7 @@ export function useProjectPathSearch( return { entries: result.data?.entries ?? [], - error: result.error, + error: canReadFiles ? result.error : "This connection cannot search host files.", isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, searchedQuery: debouncedTarget.query ?? "", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 19386ecadce3..7a36c8af5ddf 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -170,6 +170,10 @@ permissions. Existing clients keep their original grants after an update; to receive newly separated permissions, pair the client again with the scopes it needs. Reconnecting or refreshing a session does not expand its grant. +`filesystem:read` allows browsing host files, opening workspace files, and viewing +local changes. Add `filesystem:write` to allow editing files or saving plans to +the workspace. These scopes control direct file access from the client. + To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts new file mode 100644 index 000000000000..cb0c5d9fa529 --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from "./auth.ts"; + +describe("authorization grants", () => { + it("decodes legacy review credentials without offering them in new grants", () => { + expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual(["review:write"]); + expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); + expect(AuthStandardClientScopes).not.toContain("review:write"); + }); +}); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8ed9655bc56b..90156f2d1570 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -85,6 +85,9 @@ export const AuthProvidersManageScope = "providers:manage" as const; export const AuthEnvironmentMaintainScope = "environment:maintain" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthSourceControlWriteScope = "source-control:write" as const; +export const AuthFilesystemReadScope = "filesystem:read" as const; +export const AuthFilesystemWriteScope = "filesystem:write" as const; +/** Retained for decoding existing credentials; grants no current RPC access. */ export const AuthReviewWriteScope = "review:write" as const; export const AuthAccessReadScope = "access:read" as const; export const AuthAccessWriteScope = "access:write" as const; @@ -97,6 +100,8 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthProvidersManageScope, AuthEnvironmentMaintainScope, AuthTerminalOperateScope, + AuthFilesystemReadScope, + AuthFilesystemWriteScope, AuthReviewWriteScope, AuthSourceControlWriteScope, AuthAccessReadScope, @@ -108,6 +113,13 @@ export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; +export const AuthGrantScope = Schema.Literals( + AuthEnvironmentScope.literals.filter((scope) => scope !== AuthReviewWriteScope), +); +export type AuthGrantScope = typeof AuthGrantScope.Type; +export const AuthGrantScopes = Schema.Array(AuthGrantScope); +export type AuthGrantScopes = typeof AuthGrantScopes.Type; + export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, @@ -115,8 +127,9 @@ export const AuthStandardClientScopes = [ AuthProvidersManageScope, AuthEnvironmentMaintainScope, AuthTerminalOperateScope, - AuthReviewWriteScope, AuthSourceControlWriteScope, + AuthFilesystemReadScope, + AuthFilesystemWriteScope, AuthRelayReadScope, ] as const; export const AuthAdministrativeScopes = [ @@ -355,7 +368,7 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T export const AuthCreatePairingCredentialInput = Schema.Struct({ label: Schema.optionalKey(TrimmedNonEmptyString), - scopes: Schema.optionalKey(AuthEnvironmentScopes), + scopes: Schema.optionalKey(AuthGrantScopes), }); export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; From 326dbd7791d96a2b58e7b163b8081b8952f62882 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:52:44 -0700 Subject: [PATCH 02/29] docs(auth): name filesystem scope in host boundary --- docs/internals/environment-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 8cb65ad6d69a..2239432ae6dd 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -45,7 +45,7 @@ do not follow this replacement rule. ## The environment is the filesystem boundary Projects are organizational boundaries, not filesystem sandboxes. -`orchestration:read` permits reading files the server account can read, including +`filesystem:read` permits reading files the server account can read, including absolute paths outside a project. This lets clients display artifacts that an agent writes in a temporary directory. Relative paths and writes still follow the [workspace path rules](../../apps/server/src/workspace/WorkspaceFileSystem.ts). From 08f8d32563550831daa8f66ddb3bea03767432d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:00:41 -0700 Subject: [PATCH 03/29] fix(auth): consolidate filesystem scope imports --- .../src/features/projects/AddProjectScreen.tsx | 14 ++++++++++---- apps/web/src/components/CommandPalette.tsx | 11 +++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 8df0950e1abe..97bf09270009 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,5 +1,3 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -36,6 +34,7 @@ import { import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + AuthFilesystemReadScope, CommandId, type EnvironmentId, type EnvironmentMachineKind, @@ -303,7 +302,11 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto setIsBrowseNavigating(true); const committed = await browseNavigation.run( async () => { - if (environment && readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) && canPreloadBrowsePath(environmentRuntime?.connectionState)) { + if ( + environment && + readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) && + canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { await loadBrowsePath({ environmentId: environment.environmentId, input: { partialPath: selectedDirectoryPath }, @@ -787,7 +790,10 @@ function FolderBrowser(props: { () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); - const canReadFiles = useEnvironmentScope(props.environment.environmentId, AuthFilesystemReadScope); + const canReadFiles = useEnvironmentScope( + props.environment.environmentId, + AuthFilesystemReadScope, + ); const browseState = useEnvironmentQuery( !canReadFiles || browseInput === null ? null diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index faf2338fc3a8..96c0d8c29e83 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,3 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope, readEnvironmentScope } from "~/state/session"; "use client"; import { @@ -32,6 +30,7 @@ import { import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + AuthFilesystemReadScope, type DesktopWslState, type EnvironmentId, type EnvironmentMachineKind, @@ -995,7 +994,8 @@ function OpenCommandPaletteDialog(props: { isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; const canBrowseFiles = useEnvironmentScope(browseEnvironmentId, AuthFilesystemReadScope); const browseQuery = useEnvironmentQuery( - canBrowseFiles && isBrowsing && + canBrowseFiles && + isBrowsing && browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject @@ -1036,7 +1036,10 @@ function OpenCommandPaletteDialog(props: { const environment = environments.find( (candidate) => candidate.environmentId === environmentId, ); - if (!readEnvironmentScope(environmentId, AuthFilesystemReadScope) || !canPreloadBrowsePath(environment?.connection.phase)) { + if ( + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environment?.connection.phase) + ) { return; } From 7d62ab62aab3755e4b67918440b9e1bc2303d58f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:06:08 -0700 Subject: [PATCH 04/29] style(auth): format filesystem scope changes --- .../features/files/ThreadFilesRouteScreen.tsx | 17 ++++--- .../files/thread-file-navigator-pane.tsx | 10 +++-- .../threads/new-task-flow-provider.tsx | 5 ++- apps/mobile/src/state/assets.ts | 20 ++++++--- apps/mobile/src/state/queries.ts | 3 +- apps/web/src/assets/assetUrls.ts | 45 ++++++++++--------- apps/web/src/components/DiffPanel.tsx | 5 ++- .../src/components/chat/ProposedPlanCard.tsx | 11 ++++- apps/web/src/state/queries.ts | 3 +- packages/contracts/src/auth.test.ts | 4 +- 10 files changed, 79 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index b041fe205220..7e0821b6e167 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -261,14 +261,15 @@ function useThreadFilesWorkspace(params: { }; } -function FilesUnavailable({ detail = "This thread does not have an active workspace path." }: { detail?: string }) { +function FilesUnavailable({ + detail = "This thread does not have an active workspace path.", +}: { + detail?: string; +}) { return ( - + ); } @@ -627,7 +628,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); const fileQuery = useEnvironmentQuery( - canReadFiles && environmentId !== null && cwd !== null && relativePath !== null && needsFileContents + canReadFiles && + environmentId !== null && + cwd !== null && + relativePath !== null && + needsFileContents ? projectEnvironment.readFile({ environmentId, input: { cwd, relativePath }, diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 12f1d247409c..7f64ededa455 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -37,10 +37,12 @@ export function ThreadFileNavigatorPane(props: { const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const canReadFiles = useEnvironmentScope(props.environmentId, AuthFilesystemReadScope); const entriesQuery = useEnvironmentQuery( - canReadFiles ? projectEnvironment.listEntries({ - environmentId: props.environmentId, - input: { cwd: props.cwd }, - }) : null, + canReadFiles + ? projectEnvironment.listEntries({ + environmentId: props.environmentId, + input: { cwd: props.cwd }, + }) + : null, ); const entriesData = entriesQuery.data as ProjectListEntriesResult | null; const handlePreviewFile = useCallback( diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee6aae26c261..5e3b705410d1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -380,7 +380,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's // checked-in t3.json, then the server's configured default. - const canReadFiles = useEnvironmentScope(selectedProject?.environmentId ?? null, AuthFilesystemReadScope); + const canReadFiles = useEnvironmentScope( + selectedProject?.environmentId ?? null, + AuthFilesystemReadScope, + ); const t3ProjectFileQuery = useEnvironmentQuery( canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 4b770420059f..681c8ae4a3cc 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -43,7 +43,8 @@ export function useAssetUrlState( resource: AssetResource | null, ): AssetUrlState { const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); - const canReadResource = canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); + const canReadResource = + canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); const connectionPhase = useConnectionPhase(environmentId); const result = useAtomValue( @@ -51,10 +52,12 @@ export function useAssetUrlState( ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - const shared = !canReadResource ? { _tag: "Failure" as const } : assetUrlStateFromResult( - result, - preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, - ); + const shared = !canReadResource + ? { _tag: "Failure" as const } + : assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); return deriveAssetUrlState({ connectionPhase, // A failure left over from an outage is re-queried as soon as the @@ -85,8 +88,11 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - if ((resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(environmentId, AuthFilesystemReadScope)) return null; + if ( + (resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) + ) + return null; const state = assetUrlStateFromResult( await createUrl({ environmentId, input: { resource } }), httpBaseUrl, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 90cf975d62bc..43e604caede4 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -257,7 +257,8 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); const result = useEnvironmentQuery( - canReadFiles && debouncedTarget.environmentId !== null && + canReadFiles && + debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && debouncedTarget.query.length > 0 ? projectEnvironment.searchEntries({ diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index c56f4d094404..c0a5167efb7a 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -22,7 +22,8 @@ export function useAssetUrlState( resource: AssetResource | null, ): AssetUrlState { const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); - const canReadResource = canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); + const canReadResource = + canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( !canReadResource || environmentId === null || resource === null @@ -54,8 +55,11 @@ export function useAssetUrlRefresh( }); return useCallback(async () => { if (environmentId === null || resource === null) return; - if ((resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(environmentId, AuthFilesystemReadScope)) return; + if ( + (resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) + ) + return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); @@ -68,9 +72,12 @@ export function useAssetUrls( const preparedConnection = usePreparedConnection(environmentId); const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); const allowedResources = useMemo( - () => canReadFiles - ? resources - : resources.filter((resource) => resource._tag !== "workspace-file" && resource._tag !== "media-file"), + () => + canReadFiles + ? resources + : resources.filter( + (resource) => resource._tag !== "workspace-file" && resource._tag !== "media-file", + ), [canReadFiles, resources], ); const results = useAtomValue( @@ -79,18 +86,16 @@ export function useAssetUrls( resources: allowedResources, }), ); - return useMemo( - () => { - if (preparedConnection._tag === "None") return resources.map(() => null); - let resultIndex = 0; - return resources.map((resource) => { - if (!canReadFiles && (resource._tag === "workspace-file" || resource._tag === "media-file")) return null; - const result = results[resultIndex++]; - return result && AsyncResult.isSuccess(result) - ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) - : null; - }); - }, - [canReadFiles, preparedConnection, resources, results], - ); + return useMemo(() => { + if (preparedConnection._tag === "None") return resources.map(() => null); + let resultIndex = 0; + return resources.map((resource) => { + if (!canReadFiles && (resource._tag === "workspace-file" || resource._tag === "media-file")) + return null; + const result = results[resultIndex++]; + return result && AsyncResult.isSuccess(result) + ? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl) + : null; + }); + }, [canReadFiles, preparedConnection, resources, results]); } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ca80c007906c..2e672cf5d071 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -140,7 +140,10 @@ export default function DiffPanel({ }); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); - const canReadFiles = useEnvironmentScope(activeThread?.environmentId ?? null, AuthFilesystemReadScope); + const canReadFiles = useEnvironmentScope( + activeThread?.environmentId ?? null, + AuthFilesystemReadScope, + ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useProject( activeThread && activeProjectId diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index a19ec1b055bc..d8ed95ba3e5c 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -3,7 +3,11 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { AuthFilesystemWriteScope, type EnvironmentId, type ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthFilesystemWriteScope, + type EnvironmentId, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -166,7 +170,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ {isCopied ? "Copied!" : "Copy to clipboard"} Download as markdown - + Save to workspace diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index b0b2d12b90b7..f8ff1147615b 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -274,7 +274,8 @@ export function useProjectPathSearch( const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); const result = useEnvironmentQuery( - canReadFiles && debouncedTarget.environmentId !== null && + canReadFiles && + debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && debouncedTarget.query !== null && (allowEmptyQuery || debouncedTarget.query.length > 0) diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts index cb0c5d9fa529..12cf77411c73 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -5,7 +5,9 @@ import { AuthEnvironmentScopes, AuthGrantScopes, AuthStandardClientScopes } from describe("authorization grants", () => { it("decodes legacy review credentials without offering them in new grants", () => { - expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual(["review:write"]); + expect(Schema.decodeUnknownSync(AuthEnvironmentScopes)(["review:write"])).toEqual([ + "review:write", + ]); expect(() => Schema.decodeUnknownSync(AuthGrantScopes)(["review:write"])).toThrow(); expect(AuthStandardClientScopes).not.toContain("review:write"); }); From 2c1eee3684c0216e4adf06fa8ee839a91b70f198 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:07:33 -0700 Subject: [PATCH 05/29] test(web): retain file preview subscription during scope changes --- .../files/projectFilesQueryState.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index 1f43ab917827..7e9773b95347 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -3,6 +3,8 @@ import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { clearProjectFileQueryData, @@ -22,9 +24,21 @@ describe("project files queries", () => { vi.unstubAllGlobals(); }); - it("resumes an unsaved draft after write access returns and the editor reopens", async () => { + it("resumes an open preview's unsaved draft when write access returns and its editor remounts", async ({ + onTestFinished, + }) => { vi.stubGlobal("window", {}); vi.useFakeTimers(); + // The preview keeps its optimistic file subscription while its editor becomes read-only. + onTestFinished( + appAtomRegistry.mount( + projectEnvironment.optimisticFile({ + environmentId, + cwd: "/repo", + relativePath: "convex.json", + }), + ), + ); let canWrite = true; const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); const onPendingChange = vi.fn(); From 278a25d8de8cef51494cdf899abdaee28fa46596 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:10:51 -0700 Subject: [PATCH 06/29] fix(web): preserve unsaved files after permission loss --- .../files/projectFilesQueryState.test.ts | 37 ++++++++++++------- .../files/projectFilesQueryState.ts | 20 +++++++++- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index 7e9773b95347..30763b8e8d3d 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -16,6 +16,11 @@ import { } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-project-files-query-test"); +const optimisticFile = projectEnvironment.optimisticFile({ + environmentId, + cwd: "/repo", + relativePath: "convex.json", +}); describe("project files queries", () => { afterEach(() => { @@ -24,21 +29,10 @@ describe("project files queries", () => { vi.unstubAllGlobals(); }); - it("resumes an open preview's unsaved draft when write access returns and its editor remounts", async ({ - onTestFinished, - }) => { + it("resumes an unsaved draft after closing the preview and restoring write access", async () => { vi.stubGlobal("window", {}); vi.useFakeTimers(); - // The preview keeps its optimistic file subscription while its editor becomes read-only. - onTestFinished( - appAtomRegistry.mount( - projectEnvironment.optimisticFile({ - environmentId, - cwd: "/repo", - relativePath: "convex.json", - }), - ), - ); + const closePreview = appAtomRegistry.mount(optimisticFile); let canWrite = true; const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); const onPendingChange = vi.fn(); @@ -57,6 +51,7 @@ describe("project files queries", () => { initial.change("unsaved draft"); canWrite = false; initial.dispose(); + closePreview(); await vi.runAllTimersAsync(); expect(persist).not.toHaveBeenCalled(); @@ -74,6 +69,22 @@ describe("project files queries", () => { expect(onPendingChange).toHaveBeenLastCalledWith(false); expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); reopened.dispose(); + await vi.advanceTimersByTimeAsync(0); + expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); + }); + + it("releases a retained unsaved draft when explicitly cleared", async () => { + vi.useFakeTimers(); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft"); + await vi.runAllTimersAsync(); + expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( + "latest draft", + ); + + clearProjectFileQueryData(environmentId, "/repo", "convex.json"); + await vi.runAllTimersAsync(); + expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); }); it("keeps the latest optimistic draft when an older write finishes", () => { diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 76c239dd9b3f..2a9bcd887a64 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -31,6 +31,15 @@ function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativeP return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } +// Dirty contents must survive a closed preview, including failed or unauthorized saves. +const unsavedFileMounts = new Map, () => void>(); + +function releaseUnsavedFile(atom: ReturnType): void { + const unmount = unsavedFileMounts.get(atom); + unsavedFileMounts.delete(atom); + unmount?.(); +} + interface ProjectQueryState { readonly data: A | null; readonly error: string | null; @@ -59,7 +68,11 @@ export function setProjectFileQueryData( relativePath: string, contents: string, ): void { - appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), { + const atom = optimisticFileAtom(environmentId, cwd, relativePath); + if (!unsavedFileMounts.has(atom)) { + unsavedFileMounts.set(atom, appAtomRegistry.mount(atom)); + } + appAtomRegistry.set(atom, { confirmedAgainst: undefined, data: { relativePath, @@ -103,6 +116,7 @@ export function confirmProjectFileQueryData( confirmedAgainst: appAtomRegistry.get(queryAtom), }; appAtomRegistry.set(atom, confirmed); + releaseUnsavedFile(atom); appAtomRegistry.refresh(queryAtom); void executeAtomQuery(appAtomRegistry, queryAtom, { reportDefect: false, @@ -130,7 +144,9 @@ export function clearProjectFileQueryData( cwd: string, relativePath: string, ): void { - appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); + const atom = optimisticFileAtom(environmentId, cwd, relativePath); + appAtomRegistry.set(atom, null); + releaseUnsavedFile(atom); } function errorMessage(result: AsyncResult.AsyncResult): string | null { From eefc5f92e21df459611fb7e9b1fe26559cd6dbe9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:15:27 -0700 Subject: [PATCH 07/29] fix(web): keep newer file edits marked unsaved --- .../components/files/fileSaveCoordinator.ts | 8 +- .../files/projectFilesQueryState.test.ts | 89 +++++++++++++++++-- .../files/useFileSaveCoordinator.ts | 5 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/files/fileSaveCoordinator.ts b/apps/web/src/components/files/fileSaveCoordinator.ts index 09370296e5bd..aa907cdc7753 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -5,7 +5,8 @@ export interface FileSaveCoordinatorOptions { readonly canPersist?: () => boolean; readonly persist: (contents: string) => Promise>; readonly onPendingChange: (pending: boolean) => void; - readonly onConfirmed: (contents: string) => void; + /** Return false when another editor has newer unsaved contents. */ + readonly onConfirmed: (contents: string) => boolean | void; } export class FileSaveCoordinator { @@ -59,14 +60,15 @@ export class FileSaveCoordinator { const revision = this.latestRevision; const result = await this.options.persist(contents); const succeeded = result._tag === "Success"; + let confirmed = false; if (succeeded) { this.confirmedRevision = revision; - this.options.onConfirmed(contents); + confirmed = this.options.onConfirmed(contents) !== false; } this.saving = false; if (revision === this.latestRevision) { - if (succeeded) this.options.onPendingChange(false); + if (confirmed) this.options.onPendingChange(false); return; } diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index 30763b8e8d3d..a702371e8b93 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -3,6 +3,22 @@ import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +const registryTasks = vi.hoisted(() => new Set<() => void>()); + +vi.mock("~/rpc/atomRegistry", async () => { + const { AtomRegistry } = await import("effect/unstable/reactivity"); + return { + appAtomRegistry: AtomRegistry.make({ + scheduleTask: (task) => { + registryTasks.add(task); + return () => { + registryTasks.delete(task); + }; + }, + }), + }; +}); + import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; @@ -22,9 +38,18 @@ const optimisticFile = projectEnvironment.optimisticFile({ relativePath: "convex.json", }); +function drainRegistryTasks(): void { + while (registryTasks.size > 0) { + const tasks = [...registryTasks]; + registryTasks.clear(); + for (const task of tasks) task(); + } +} + describe("project files queries", () => { afterEach(() => { clearProjectFileQueryData(environmentId, "/repo", "convex.json"); + drainRegistryTasks(); vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -42,9 +67,8 @@ describe("project files queries", () => { canPersist: () => canWrite, persist, onPendingChange, - onConfirmed: (contents) => { - confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents); - }, + onConfirmed: (contents) => + confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents), }); const initial = makeCoordinator(); setProjectFileQueryData(environmentId, "/repo", "convex.json", "unsaved draft"); @@ -53,6 +77,7 @@ describe("project files queries", () => { initial.dispose(); closePreview(); await vi.runAllTimersAsync(); + drainRegistryTasks(); expect(persist).not.toHaveBeenCalled(); expect(onPendingChange).toHaveBeenLastCalledWith(true); @@ -69,24 +94,72 @@ describe("project files queries", () => { expect(onPendingChange).toHaveBeenLastCalledWith(false); expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); reopened.dispose(); - await vi.advanceTimersByTimeAsync(0); + drainRegistryTasks(); expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); }); - it("releases a retained unsaved draft when explicitly cleared", async () => { - vi.useFakeTimers(); + it("releases a retained unsaved draft when explicitly cleared", () => { setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft"); - await vi.runAllTimersAsync(); + drainRegistryTasks(); expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( "latest draft", ); clearProjectFileQueryData(environmentId, "/repo", "convex.json"); - await vi.runAllTimersAsync(); + drainRegistryTasks(); expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); }); + it("keeps a reopened editor's newer draft pending when the old editor's write finishes", async () => { + vi.stubGlobal("window", {}); + vi.useFakeTimers(); + let canWrite = true; + const saved = AsyncResult.success(undefined); + let finishFirstWrite!: (result: typeof saved) => void; + const firstWrite = new Promise((resolve) => { + finishFirstWrite = resolve; + }); + const persist = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(saved); + const onPendingChange = vi.fn(); + const makeCoordinator = () => + new FileSaveCoordinator({ + debounceMs: 500, + canPersist: () => canWrite, + persist, + onPendingChange, + onConfirmed: (contents) => + confirmProjectFileQueryData(environmentId, "/repo", "convex.json", contents), + }); + + const initial = makeCoordinator(); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); + initial.change("first draft"); + await vi.advanceTimersByTimeAsync(500); + initial.dispose(); + + const reopened = makeCoordinator(); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "newer draft"); + reopened.change("newer draft"); + canWrite = false; + finishFirstWrite(saved); + await vi.runAllTimersAsync(); + + expect(persist).toHaveBeenCalledOnce(); + expect(onPendingChange).toHaveBeenLastCalledWith(true); + expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( + "newer draft", + ); + + canWrite = true; + reopened.change("newer draft"); + await vi.advanceTimersByTimeAsync(500); + expect(persist).toHaveBeenLastCalledWith("newer draft"); + expect(onPendingChange).toHaveBeenLastCalledWith(false); + expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")).toBeNull(); + reopened.dispose(); + }); + it("keeps the latest optimistic draft when an older write finishes", () => { vi.stubGlobal("window", {}); const initial = { diff --git a/apps/web/src/components/files/useFileSaveCoordinator.ts b/apps/web/src/components/files/useFileSaveCoordinator.ts index a2d7074c361e..2ac6ee67263b 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.ts +++ b/apps/web/src/components/files/useFileSaveCoordinator.ts @@ -42,9 +42,8 @@ export function useFileSaveCoordinator({ environmentId, input: { cwd, relativePath, contents: nextContents }, }), - onConfirmed: (confirmedContents) => { - confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); - }, + onConfirmed: (confirmedContents) => + confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents), }); coordinatorRef.current = coordinator; return () => { From 3dbcabcbfa8efb77ceaf90d749142a50f995084b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:20:07 -0700 Subject: [PATCH 08/29] fix(mobile): hide cached local diffs without file access --- .../features/review/useReviewSections.test.ts | 101 ++++++++++++++++++ .../src/features/review/useReviewSections.ts | 3 +- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/review/useReviewSections.test.ts diff --git a/apps/mobile/src/features/review/useReviewSections.test.ts b/apps/mobile/src/features/review/useReviewSections.test.ts new file mode 100644 index 000000000000..b31a2a63709d --- /dev/null +++ b/apps/mobile/src/features/review/useReviewSections.test.ts @@ -0,0 +1,101 @@ +import { + CheckpointRef, + EnvironmentId, + MessageId, + ThreadId, + TurnId, + type OrchestrationCheckpointSummary, +} from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canReadFiles: true, + checkpoints: [] as ReadonlyArray, +})); + +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => {}, + useMemo: (factory: () => A) => factory(), +})); +vi.mock("../../state/session", () => ({ + useEnvironmentScope: () => state.canReadFiles, +})); +vi.mock("../../state/use-thread-detail", () => ({ + useSelectedThreadDetail: () => ({ checkpoints: state.checkpoints }), +})); +vi.mock("../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }), +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); +vi.mock("../../state/queries", () => ({ + useCheckpointDiff: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); +vi.mock("../../state/review", () => ({ + reviewEnvironment: { diffPreview: vi.fn() }, +})); +vi.mock("./reviewState", () => ({ + setReviewAsyncError: vi.fn(), + setReviewGitSections: vi.fn(), + setReviewSelectedSectionId: vi.fn(), + setReviewTurnDiff: vi.fn(), + setReviewTurnDiffLoading: vi.fn(), +})); + +import type { ReviewCacheForThread } from "./reviewState"; +import { useReviewSections } from "./useReviewSections"; + +it("hides cached local diffs after file access is lost while retaining checkpoint diffs", () => { + state.checkpoints = [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("message-1"), + completedAt: "2026-04-01T00:00:00.000Z", + }, + ]; + const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts"; + const localDiff = "diff --git a/local.ts b/local.ts"; + const reviewCache: ReviewCacheForThread = { + threadKey: "environment:thread", + gitSections: [ + { + id: "working-tree", + kind: "working-tree", + title: "Dirty worktree", + baseRef: "HEAD", + headRef: null, + diff: localDiff, + diffHash: "cached-local", + truncated: false, + }, + ], + turnDiffById: { "turn:1": checkpointDiff }, + selectedSectionId: "git:working-tree", + asyncState: { loadingTurnIds: {}, error: null }, + expandedFileIdsBySection: {}, + revealedLargeFileIdsBySection: {}, + viewedFileIdsBySection: {}, + }; + const input = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + reviewCache, + }; + + state.canReadFiles = true; + expect(useReviewSections(input).selectedSection?.diff).toBe(localDiff); + + state.canReadFiles = false; + const denied = useReviewSections(input); + expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]); + expect(denied.selectedSection?.diff).toBe(checkpointDiff); + + state.canReadFiles = true; + expect(useReviewSections(input).selectedSection?.diff).toBe(localDiff); +}); diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index a71895c05ac5..be7837124767 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -69,12 +69,13 @@ export function useReviewSections(input: { () => buildReviewSectionItems({ checkpoints: readyCheckpoints, - gitSections: reviewCache.gitSections, + gitSections: canReadFiles ? reviewCache.gitSections : [], turnDiffById: reviewCache.turnDiffById, loadingTurnIds, loadingGitSections: diffPreview.isPending, }), [ + canReadFiles, diffPreview.isPending, loadingTurnIds, readyCheckpoints, From 09e236edbfa815f7aca1183694ef7b82992dad2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:26:38 -0700 Subject: [PATCH 09/29] fix(mobile): await file access before choosing workspace defaults --- .../threads/new-task-flow-provider.tsx | 19 ++++++++++++---- packages/shared/src/threadEnvMode.test.ts | 22 +++++++++++++++++++ packages/shared/src/threadEnvMode.ts | 5 +++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5e3b705410d1..dc8c0851cc40 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,5 +1,5 @@ import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "../../state/session"; +import { environmentSession } from "../../state/session"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { @@ -380,10 +380,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's // checked-in t3.json, then the server's configured default. - const canReadFiles = useEnvironmentScope( - selectedProject?.environmentId ?? null, - AuthFilesystemReadScope, + const fileAccessSession = useEnvironmentQuery( + selectedProject !== null && selectedProject.workspaceRoot !== "" + ? environmentSession.sessionStateAtom(selectedProject.environmentId) + : null, ); + const fileAccessPending = + selectedProject !== null && + selectedProject.workspaceRoot !== "" && + fileAccessSession.data === null && + fileAccessSession.error === null; + const canReadFiles = + fileAccessSession.error === null && + fileAccessSession.data?.authenticated === true && + fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; const t3ProjectFileQuery = useEnvironmentQuery( canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ @@ -409,6 +419,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { explicitMode: selectedProjectDraft.workspaceSelection?.mode, projectSetting: selectedProject?.defaultThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, + projectFilePermissionPending: fileAccessPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; diff --git a/packages/shared/src/threadEnvMode.test.ts b/packages/shared/src/threadEnvMode.test.ts index 4cf22c248868..411beae63d7d 100644 --- a/packages/shared/src/threadEnvMode.test.ts +++ b/packages/shared/src/threadEnvMode.test.ts @@ -29,6 +29,28 @@ describe("resolveDefaultThreadEnvMode", () => { }); describe("isDefaultThreadEnvModeSettled", () => { + it("waits for file permission before accepting a fallback while the file query is paused", () => { + const sources = { + explicitMode: undefined, + projectSetting: null, + projectFilePending: false, + projectFilePermissionPending: true, + }; + expect(isDefaultThreadEnvModeSettled(sources)).toBe(false); + expect( + isDefaultThreadEnvModeSettled({ + ...sources, + projectFilePermissionPending: false, + projectFilePending: true, + }), + ).toBe(false); + expect(isDefaultThreadEnvModeSettled({ ...sources, projectFilePermissionPending: false })).toBe( + true, + ); + expect(isDefaultThreadEnvModeSettled({ ...sources, explicitMode: "local" })).toBe(true); + expect(isDefaultThreadEnvModeSettled({ ...sources, projectSetting: "local" })).toBe(true); + }); + it("settles on an explicit pick or project setting even while the file loads", () => { expect( isDefaultThreadEnvModeSettled({ diff --git a/packages/shared/src/threadEnvMode.ts b/packages/shared/src/threadEnvMode.ts index 4c01c0f27b91..ac2e2fa0b3ce 100644 --- a/packages/shared/src/threadEnvMode.ts +++ b/packages/shared/src/threadEnvMode.ts @@ -19,7 +19,7 @@ export function resolveDefaultThreadEnvMode(sources: { /** * True once the resolved default can no longer change: an explicit pick or a - * source that outranks t3.json decided, or the file read settled. While + * source that outranks t3.json decided, or its permission lookup and file read settled. While * false, nothing may persist the provisional default (for example into a * draft's workspace selection) — it could differ from the final value. */ @@ -27,10 +27,11 @@ export function isDefaultThreadEnvModeSettled(sources: { readonly explicitMode: ThreadEnvMode | undefined; readonly projectSetting: ThreadEnvMode | null | undefined; readonly projectFilePending: boolean; + readonly projectFilePermissionPending?: boolean; }): boolean { return ( sources.explicitMode !== undefined || sources.projectSetting != null || - !sources.projectFilePending + (!sources.projectFilePending && !sources.projectFilePermissionPending) ); } From b04c3bd67c46243e4c7a8e39a89a89eba1b195bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:39:24 -0700 Subject: [PATCH 10/29] style(web): format filesystem markdown guards --- apps/web/src/components/ChatMarkdown.tsx | 40 +++++++++++++++++------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6bc7dad311cf..580cbcba3c86 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2157,17 +2157,28 @@ function useChatMarkdownState({ reportFailure: false, refresh: true, }); - const createAssetUrl = useCallback((input) => { - const resource = input.input.resource; - if ((resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope)) { - return Promise.resolve(AsyncResult.failure(Cause.fail(new EnvironmentAuthorizationError({ - message: "This connection cannot read host files.", - requiredScope: AuthFilesystemReadScope, - })))); - } - return loadAssetUrl(input); - }, [loadAssetUrl]); + const createAssetUrl = useCallback( + (input) => { + const resource = input.input.resource; + if ( + (resource._tag === "workspace-file" || resource._tag === "media-file") && + !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) + ) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection cannot read host files.", + requiredScope: AuthFilesystemReadScope, + }), + ), + ), + ); + } + return loadAssetUrl(input); + }, + [loadAssetUrl], + ); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); @@ -2424,7 +2435,12 @@ function useChatMarkdownState({ ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { - if (!cwd || environmentId === null || !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + if ( + !cwd || + environmentId === null || + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || + !needsWorkspaceBasenameLookup(workspaceRelativePath) + ) { return null; } const result = await searchProjectEntries({ From f1c732abe6bcc8394cd0ed4ffcbfcf8822f2db95 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:50:06 -0700 Subject: [PATCH 11/29] fix(web): explain missing local diff access --- apps/web/src/components/DiffPanel.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 2e672cf5d071..957b389c9d57 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,5 +1,5 @@ import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "~/state/session"; +import { environmentSession } from "~/state/session"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -140,10 +140,13 @@ export default function DiffPanel({ }); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); - const canReadFiles = useEnvironmentScope( - activeThread?.environmentId ?? null, - AuthFilesystemReadScope, + const fileAccessSession = useEnvironmentQuery( + activeThread ? environmentSession.sessionStateAtom(activeThread.environmentId) : null, ); + const canReadFiles = + fileAccessSession.error === null && + fileAccessSession.data?.authenticated === true && + fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; const activeProjectId = activeThread?.projectId ?? null; const activeProject = useProject( activeThread && activeProjectId @@ -906,6 +909,14 @@ export default function DiffPanel({
No completed turns yet.
+ ) : selectedTurnId === null && !canReadFiles ? ( + fileAccessSession.data === null && fileAccessSession.error === null ? ( + + ) : ( +
+ {fileAccessSession.error ?? "This connection cannot read local diffs."} +
+ ) ) : ( <>
From f49735cef974fd70e85860d2d92603d538e96129 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:59:27 -0700 Subject: [PATCH 12/29] fix(files): wait for permissions before showing denial --- .../features/files/ThreadFilesRouteScreen.tsx | 40 ++++++++++++++++--- .../files/thread-file-navigator-pane.tsx | 21 ++++++++-- .../src/components/files/FilePreviewPanel.tsx | 19 +++++++-- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 7e0821b6e167..5ed5f3b478bd 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,5 +1,5 @@ import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "../../state/session"; +import { environmentSession } from "../../state/session"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; @@ -316,7 +316,13 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { props.route.params, ); const revealedInspectorRef = useRef(false); - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, + ); + const canReadFiles = + fileAccessSession.error === null && + fileAccessSession.data?.authenticated === true && + fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; const entriesQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && cwd !== null && !fileInspector.supported ? projectEnvironment.listEntries({ @@ -410,7 +416,16 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { return ; } - if (!canReadFiles) return ; + if (!canReadFiles) { + if (fileAccessSession.data === null && fileAccessSession.error === null) { + return ; + } + return ( + + ); + } if (cwd === null) { return ; } @@ -626,7 +641,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { relativePath !== null && !isVideoFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, + ); + const canReadFiles = + fileAccessSession.error === null && + fileAccessSession.data?.authenticated === true && + fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; const fileQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && @@ -809,7 +830,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { return ; } - if (!canReadFiles) return ; + if (!canReadFiles) { + if (fileAccessSession.data === null && fileAccessSession.error === null) { + return ; + } + return ( + + ); + } if (cwd === null) { return ; } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 7f64ededa455..24e3808dffb0 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,5 +1,5 @@ import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "../../state/session"; +import { environmentSession } from "../../state/session"; import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; @@ -35,7 +35,14 @@ export function ThreadFileNavigatorPane(props: { const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); - const canReadFiles = useEnvironmentScope(props.environmentId, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentSession.sessionStateAtom(props.environmentId), + ); + const fileAccessPending = fileAccessSession.data === null && fileAccessSession.error === null; + const canReadFiles = + fileAccessSession.error === null && + fileAccessSession.data?.authenticated === true && + fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; const entriesQuery = useEnvironmentQuery( canReadFiles ? projectEnvironment.listEntries({ @@ -76,8 +83,14 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( + + Checking file access... +
+ ); + } return (
- This connection cannot read host files. + {fileAccessSession.error ?? "This connection cannot read host files."}
); } From 9fb3e74177804940f58c541c0e80d456191dc5ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:06:58 -0700 Subject: [PATCH 13/29] fix(files): stop waiting for offline permission checks --- .../features/files/ThreadFilesRouteScreen.tsx | 37 +++++----- .../files/thread-file-navigator-pane.tsx | 21 +++--- apps/web/src/components/DiffPanel.tsx | 18 +++-- .../src/components/files/FilePreviewPanel.tsx | 19 +++-- .../src/state/filesystem.test.ts | 73 +++++++++++++++++++ .../client-runtime/src/state/filesystem.ts | 41 ++++++++++- 6 files changed, 167 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 5ed5f3b478bd..672d87c37dc8 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,4 +1,4 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "../../state/session"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -38,6 +38,7 @@ import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { projectEnvironment } from "../../state/projects"; import type { AssetUrlFailureReason } from "../../state/asset-url-state"; import { @@ -319,10 +320,13 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const entriesQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && cwd !== null && !fileInspector.supported ? projectEnvironment.listEntries({ @@ -417,13 +421,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { } if (!canReadFiles) { - if (fileAccessSession.data === null && fileAccessSession.error === null) { + if (fileAccess.isPending) { return ; } return ( - + ); } if (cwd === null) { @@ -644,10 +646,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const fileQuery = useEnvironmentQuery( canReadFiles && environmentId !== null && @@ -831,13 +836,11 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } if (!canReadFiles) { - if (fileAccessSession.data === null && fileAccessSession.error === null) { + if (fileAccess.isPending) { return ; } return ( - + ); } if (cwd === null) { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 24e3808dffb0..c5818f211731 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,4 +1,4 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "../../state/session"; import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; @@ -17,6 +17,7 @@ import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; @@ -38,11 +39,13 @@ export function ThreadFileNavigatorPane(props: { const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environmentId), ); - const fileAccessPending = fileAccessSession.data === null && fileAccessSession.error === null; - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + const fileEnvironment = useEnvironmentPresentation(props.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const entriesQuery = useEnvironmentQuery( canReadFiles ? projectEnvironment.listEntries({ @@ -86,11 +89,11 @@ export function ThreadFileNavigatorPane(props: { error={ canReadFiles ? entriesQuery.error - : fileAccessPending + : fileAccess.isPending ? null - : (fileAccessSession.error ?? "This connection cannot read host files.") + : (fileAccess.error ?? "This connection cannot read host files.") } - isPending={fileAccessPending || entriesQuery.isPending} + isPending={fileAccess.isPending || entriesQuery.isPending} searchQuery={searchQuery} selectedPath={props.selectedPath} onPreviewFile={handlePreviewFile} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 957b389c9d57..8a4164c2ff59 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,5 +1,6 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "~/state/session"; +import { useEnvironmentPresentation } from "~/state/presentation"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -143,10 +144,13 @@ export default function DiffPanel({ const fileAccessSession = useEnvironmentQuery( activeThread ? environmentSession.sessionStateAtom(activeThread.environmentId) : null, ); - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + const fileEnvironment = useEnvironmentPresentation(activeThread?.environmentId ?? null); + const fileAccess = resolveFilesystemReadAccess({ + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const activeProjectId = activeThread?.projectId ?? null; const activeProject = useProject( activeThread && activeProjectId @@ -910,11 +914,11 @@ export default function DiffPanel({ No completed turns yet.
) : selectedTurnId === null && !canReadFiles ? ( - fileAccessSession.data === null && fileAccessSession.error === null ? ( + fileAccess.isPending ? ( ) : (
- {fileAccessSession.error ?? "This connection cannot read local diffs."} + {fileAccess.error ?? "This connection cannot read local diffs."}
) ) : ( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3e6042d9e568..843ef6e69bbe 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -5,7 +5,8 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; -import { AuthFilesystemReadScope, AuthFilesystemWriteScope } from "@t3tools/contracts"; +import { AuthFilesystemWriteScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -48,6 +49,7 @@ import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentPresentation } from "~/state/presentation"; import { useEnvironmentQuery } from "~/state/query"; import { environmentSession, useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -992,10 +994,13 @@ export default function FilePreviewPanel({ const isHostFile = attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); const fileAccessSession = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const file = useProjectFileQuery( environmentId, @@ -1114,7 +1119,7 @@ export default function FilePreviewPanel({ ]); if (attachment === undefined && !canReadFiles) { - if (fileAccessSession.data === null && fileAccessSession.error === null) { + if (fileAccess.isPending) { return (
@@ -1124,7 +1129,7 @@ export default function FilePreviewPanel({ } return (
- {fileAccessSession.error ?? "This connection cannot read host files."} + {fileAccess.error ?? "This connection cannot read host files."}
); } diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 44e3df6ab267..79c9d12d0a4e 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -1,12 +1,85 @@ import { describe, expect, it } from "vite-plus/test"; +import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; import { canPreloadBrowsePath, createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, + resolveFilesystemReadAccess, } from "./filesystem.ts"; +describe("filesystem read access", () => { + it.each(["available", "offline", "error", null] as const)( + "stops waiting for an unresolved session when the connection is %s", + (phase) => { + expect( + resolveFilesystemReadAccess({ + connection: phase === null ? null : { phase, error: null }, + session: null, + sessionError: null, + }), + ).toEqual({ + canReadFiles: false, + isPending: false, + error: "This environment is not connected.", + }); + }, + ); + + it.each(["connected", "connecting", "reconnecting"] as const)( + "waits for the session check while %s", + (phase) => { + expect( + resolveFilesystemReadAccess({ + connection: { phase, error: null }, + session: null, + sessionError: null, + }), + ).toEqual({ canReadFiles: false, isPending: true, error: null }); + }, + ); + + it("reports the transport failure when the session cannot be checked", () => { + expect( + resolveFilesystemReadAccess({ + connection: { phase: "error", error: "The relay is unavailable." }, + session: null, + sessionError: null, + }), + ).toEqual({ canReadFiles: false, isPending: false, error: "The relay is unavailable." }); + }); + + it("preserves a cached file grant offline unless the session check failed", () => { + const input = { + connection: { phase: "offline", error: null }, + session: { authenticated: true, scopes: [AuthFilesystemReadScope] }, + sessionError: null, + } as const; + expect(resolveFilesystemReadAccess(input)).toEqual({ + canReadFiles: true, + isPending: false, + error: null, + }); + expect( + resolveFilesystemReadAccess({ ...input, sessionError: "The session has expired." }), + ).toEqual({ canReadFiles: false, isPending: false, error: "The session has expired." }); + }); + + it.each([ + { authenticated: true, scopes: [AuthOrchestrationReadScope] }, + { authenticated: false, scopes: [AuthFilesystemReadScope] }, + ] as const)("does not infer file access from an ungranted session", (session) => { + expect( + resolveFilesystemReadAccess({ + connection: { phase: "connected", error: null }, + session, + sessionError: null, + }), + ).toEqual({ canReadFiles: false, isPending: false, error: null }); + }); +}); + describe("filesystem browse model", () => { it("derives the browse target and navigation state", () => { expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 794dc404147d..f9df887e2a24 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,7 +1,15 @@ -import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + type AuthSessionState, + type FilesystemBrowseEntry, + WS_METHODS, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; +import type { + EnvironmentConnectionPhase, + EnvironmentConnectionPresentation, +} from "../connection/presentation.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { canNavigateUp, @@ -13,6 +21,35 @@ import { } from "./projects.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +export function resolveFilesystemReadAccess(input: { + readonly connection: Pick | null; + readonly session: Pick | null; + readonly sessionError: string | null; +}) { + if (input.sessionError !== null) { + return { canReadFiles: false, isPending: false, error: input.sessionError }; + } + if (input.session === null) { + // An offline, unprepared environment cannot finish its session check. + const isPending = + input.connection?.phase === "connected" || + input.connection?.phase === "connecting" || + input.connection?.phase === "reconnecting"; + return { + canReadFiles: false, + isPending, + error: isPending ? null : (input.connection?.error ?? "This environment is not connected."), + }; + } + return { + canReadFiles: + input.session.authenticated && + input.session.scopes?.includes(AuthFilesystemReadScope) === true, + isPending: false, + error: null, + }; +} + export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; From cea8f765be45afd91233a3b9b580c36f55140bda Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:14:18 -0700 Subject: [PATCH 14/29] fix(files): wait for the initial environment catalog --- .../features/files/ThreadFilesRouteScreen.tsx | 2 + .../files/thread-file-navigator-pane.tsx | 1 + apps/web/src/components/DiffPanel.tsx | 1 + .../src/components/files/FilePreviewPanel.tsx | 1 + .../src/state/filesystem.test.ts | 68 ++++++++++++++----- .../client-runtime/src/state/filesystem.ts | 5 +- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 672d87c37dc8..0d353495c965 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -322,6 +322,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); const fileEnvironment = useEnvironmentPresentation(environmentId); const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, connection: fileEnvironment.presentation?.connection ?? null, session: fileAccessSession.data, sessionError: fileAccessSession.error, @@ -648,6 +649,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); const fileEnvironment = useEnvironmentPresentation(environmentId); const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, connection: fileEnvironment.presentation?.connection ?? null, session: fileAccessSession.data, sessionError: fileAccessSession.error, diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index c5818f211731..60c7fc2670e8 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -41,6 +41,7 @@ export function ThreadFileNavigatorPane(props: { ); const fileEnvironment = useEnvironmentPresentation(props.environmentId); const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, connection: fileEnvironment.presentation?.connection ?? null, session: fileAccessSession.data, sessionError: fileAccessSession.error, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 8a4164c2ff59..e12b250c590c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -146,6 +146,7 @@ export default function DiffPanel({ ); const fileEnvironment = useEnvironmentPresentation(activeThread?.environmentId ?? null); const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, connection: fileEnvironment.presentation?.connection ?? null, session: fileAccessSession.data, sessionError: fileAccessSession.error, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 843ef6e69bbe..fb272e325653 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -996,6 +996,7 @@ export default function FilePreviewPanel({ const fileAccessSession = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); const fileEnvironment = useEnvironmentPresentation(environmentId); const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, connection: fileEnvironment.presentation?.connection ?? null, session: fileAccessSession.data, sessionError: fileAccessSession.error, diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 79c9d12d0a4e..2fc46a760e17 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -10,12 +10,39 @@ import { } from "./filesystem.ts"; describe("filesystem read access", () => { - it.each(["available", "offline", "error", null] as const)( + it("waits for the initial catalog before declaring a missing environment disconnected", () => { + expect( + resolveFilesystemReadAccess({ + isCatalogReady: false, + connection: null, + session: null, + sessionError: null, + }), + ).toEqual({ canReadFiles: false, isPending: true, error: null }); + }); + + it("stops waiting when the loaded catalog has no matching environment", () => { + expect( + resolveFilesystemReadAccess({ + isCatalogReady: true, + connection: null, + session: null, + sessionError: null, + }), + ).toEqual({ + canReadFiles: false, + isPending: false, + error: "This environment is not connected.", + }); + }); + + it.each(["available", "offline", "error"] as const)( "stops waiting for an unresolved session when the connection is %s", (phase) => { expect( resolveFilesystemReadAccess({ - connection: phase === null ? null : { phase, error: null }, + isCatalogReady: true, + connection: { phase, error: null }, session: null, sessionError: null, }), @@ -32,6 +59,7 @@ describe("filesystem read access", () => { (phase) => { expect( resolveFilesystemReadAccess({ + isCatalogReady: true, connection: { phase, error: null }, session: null, sessionError: null, @@ -43,6 +71,7 @@ describe("filesystem read access", () => { it("reports the transport failure when the session cannot be checked", () => { expect( resolveFilesystemReadAccess({ + isCatalogReady: true, connection: { phase: "error", error: "The relay is unavailable." }, session: null, sessionError: null, @@ -50,21 +79,25 @@ describe("filesystem read access", () => { ).toEqual({ canReadFiles: false, isPending: false, error: "The relay is unavailable." }); }); - it("preserves a cached file grant offline unless the session check failed", () => { - const input = { - connection: { phase: "offline", error: null }, - session: { authenticated: true, scopes: [AuthFilesystemReadScope] }, - sessionError: null, - } as const; - expect(resolveFilesystemReadAccess(input)).toEqual({ - canReadFiles: true, - isPending: false, - error: null, - }); - expect( - resolveFilesystemReadAccess({ ...input, sessionError: "The session has expired." }), - ).toEqual({ canReadFiles: false, isPending: false, error: "The session has expired." }); - }); + it.each([false, true])( + "preserves a cached file grant offline with catalog ready=%s", + (isCatalogReady) => { + const input = { + isCatalogReady, + connection: { phase: "offline", error: null }, + session: { authenticated: true, scopes: [AuthFilesystemReadScope] }, + sessionError: null, + } as const; + expect(resolveFilesystemReadAccess(input)).toEqual({ + canReadFiles: true, + isPending: false, + error: null, + }); + expect( + resolveFilesystemReadAccess({ ...input, sessionError: "The session has expired." }), + ).toEqual({ canReadFiles: false, isPending: false, error: "The session has expired." }); + }, + ); it.each([ { authenticated: true, scopes: [AuthOrchestrationReadScope] }, @@ -72,6 +105,7 @@ describe("filesystem read access", () => { ] as const)("does not infer file access from an ungranted session", (session) => { expect( resolveFilesystemReadAccess({ + isCatalogReady: true, connection: { phase: "connected", error: null }, session, sessionError: null, diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index f9df887e2a24..c82c54567cd4 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -22,6 +22,7 @@ import { import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; export function resolveFilesystemReadAccess(input: { + readonly isCatalogReady: boolean; readonly connection: Pick | null; readonly session: Pick | null; readonly sessionError: string | null; @@ -30,8 +31,10 @@ export function resolveFilesystemReadAccess(input: { return { canReadFiles: false, isPending: false, error: input.sessionError }; } if (input.session === null) { - // An offline, unprepared environment cannot finish its session check. + // Wait for the catalog before interpreting a missing presentation as offline. + // Once ready, an offline environment cannot finish its session check. const isPending = + !input.isCatalogReady || input.connection?.phase === "connected" || input.connection?.phase === "connecting" || input.connection?.phase === "reconnecting"; From 541f554d2f945b48e07fb4f65369287b922143b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 16:48:52 -0700 Subject: [PATCH 15/29] fix(files): distinguish pending access from unavailable connections --- .../features/projects/AddProjectScreen.tsx | 23 ++-- .../threads/new-task-flow-provider.tsx | 20 ++-- .../src/state/queries.filesystem.test.ts | 99 +++++++++++++++++ apps/mobile/src/state/queries.ts | 48 ++++++--- apps/web/src/state/queries.filesystem.test.ts | 102 ++++++++++++++++++ apps/web/src/state/queries.ts | 49 ++++++--- 6 files changed, 299 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/src/state/queries.filesystem.test.ts create mode 100644 apps/web/src/state/queries.filesystem.test.ts diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 97bf09270009..98b67c3a22ff 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -25,6 +25,7 @@ import { createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, + resolveFilesystemReadAccess, } from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, @@ -56,7 +57,8 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; +import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -790,10 +792,17 @@ function FolderBrowser(props: { () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); - const canReadFiles = useEnvironmentScope( - props.environment.environmentId, - AuthFilesystemReadScope, + const fileAccessSession = useEnvironmentQuery( + environmentSession.sessionStateAtom(props.environment.environmentId), ); + const fileEnvironment = useEnvironmentPresentation(props.environment.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const browseState = useEnvironmentQuery( !canReadFiles || browseInput === null ? null @@ -817,10 +826,12 @@ function FolderBrowser(props: { return ( <> Browse folders - {!canReadFiles ? : null} + {!canReadFiles && !fileAccess.isPending ? ( + + ) : null} {browseState.error ? : null} - {browseState.isPending && browseState.data === null ? ( + {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? ( diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index dc8c0851cc40..d9c060b920fa 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,5 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { environmentSession } from "../../state/session"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -385,15 +386,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ? environmentSession.sessionStateAtom(selectedProject.environmentId) : null, ); + const fileEnvironment = useEnvironmentPresentation(selectedProject?.environmentId ?? null); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const fileAccessPending = - selectedProject !== null && - selectedProject.workspaceRoot !== "" && - fileAccessSession.data === null && - fileAccessSession.error === null; - const canReadFiles = - fileAccessSession.error === null && - fileAccessSession.data?.authenticated === true && - fileAccessSession.data.scopes?.includes(AuthFilesystemReadScope) === true; + selectedProject !== null && selectedProject.workspaceRoot !== "" && fileAccess.isPending; const t3ProjectFileQuery = useEnvironmentQuery( canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ diff --git a/apps/mobile/src/state/queries.filesystem.test.ts b/apps/mobile/src/state/queries.filesystem.test.ts new file mode 100644 index 000000000000..3858e21c7ea2 --- /dev/null +++ b/apps/mobile/src/state/queries.filesystem.test.ts @@ -0,0 +1,99 @@ +import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + session: null as Pick | null, + sessionError: null as string | null, + phase: "connected" as "connected" | "offline", + sessionAtom: {}, + searchAtom: {}, +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback:
(callback: A) => callback, + useEffect: () => {}, + useMemo: (factory: () => A) => factory(), + useState: (value: A) => [value, vi.fn()], +})); +vi.mock("./session", () => ({ + environmentSession: { sessionStateAtom: () => state.sessionAtom }, +})); +vi.mock("./presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: state.phase, error: null } }, + }), +})); +vi.mock("./projects", () => ({ + projectEnvironment: { searchEntries: () => state.searchAtom }, +})); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); +vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() })); +vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: unknown) => ({ + data: + atom === state.sessionAtom + ? state.session + : atom === state.searchAtom + ? { entries: [{ path: "src/index.ts", kind: "file" }] } + : null, + error: atom === state.sessionAtom ? state.sessionError : null, + isPending: atom === state.sessionAtom && state.session === null && state.sessionError === null, + refresh: vi.fn(), + }), +})); + +import { useComposerPathSearch } from "./queries"; + +const target = { + environmentId: EnvironmentId.make("test-environment"), + cwd: "/repo", + query: "src", +}; + +beforeEach(() => { + state.session = null; + state.sessionError = null; + state.phase = "connected"; +}); + +it("keeps a file search pending until its grant loads, then shows matches", () => { + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: null, + isPending: true, + }); + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [{ path: "src/index.ts", kind: "file" }], + error: null, + isPending: false, + }); +}); + +it("shows a confirmed denial and a connection failure separately", () => { + state.session = { authenticated: true, scopes: [] }; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: "This connection cannot search host files.", + isPending: false, + }); + state.session = null; + state.phase = "offline"; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: "This environment is not connected.", + isPending: false, + }); +}); + +it("leaves an inactive search idle while its grant loads", () => { + expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({ + entries: [], + error: null, + isPending: false, + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 43e604caede4..d720322a51df 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,5 +1,6 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "./session"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +import { environmentSession } from "./session"; +import { useEnvironmentPresentation } from "./presentation"; import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, @@ -255,27 +256,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { [target.cwd, target.environmentId, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); - const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); - const result = useEnvironmentQuery( - canReadFiles && - debouncedTarget.environmentId !== null && - debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 - ? projectEnvironment.searchEntries({ + const fileAccessSession = useEnvironmentQuery( + debouncedTarget.environmentId === null + ? null + : environmentSession.sessionStateAtom(debouncedTarget.environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; + const searchTarget = + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? { environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, limit: COMPOSER_PATH_SEARCH_LIMIT, }, - }) - : null, + } + : null; + const result = useEnvironmentQuery( + canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, ); + const hasTarget = searchTarget !== null; return { entries: result.data?.entries ?? [], - error: canReadFiles ? result.error : "This connection cannot search host files.", - isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + error: + !hasTarget || fileAccess.isPending + ? null + : canReadFiles + ? result.error + : (fileAccess.error ?? "This connection cannot search host files."), + isPending: + normalizedTarget.query !== debouncedTarget.query || + (hasTarget && (fileAccess.isPending || result.isPending)), refresh: result.refresh, }; } diff --git a/apps/web/src/state/queries.filesystem.test.ts b/apps/web/src/state/queries.filesystem.test.ts new file mode 100644 index 000000000000..3497031082af --- /dev/null +++ b/apps/web/src/state/queries.filesystem.test.ts @@ -0,0 +1,102 @@ +import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + session: null as Pick | null, + sessionError: null as string | null, + phase: "connected" as "connected" | "offline", + sessionAtom: {}, + searchAtom: {}, +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: A) => callback, + useEffect: () => {}, + useMemo: (factory: () => A) => factory(), + useState: (value: A) => [value, vi.fn()], +})); +vi.mock("./session", () => ({ + environmentSession: { sessionStateAtom: () => state.sessionAtom }, +})); +vi.mock("./presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: state.phase, error: null } }, + }), +})); +vi.mock("./projects", () => ({ + projectEnvironment: { searchEntries: () => state.searchAtom }, +})); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); +vi.mock("./threads", () => ({ useEnvironmentThread: vi.fn() })); +vi.mock("./vcs", () => ({ vcsEnvironment: {} })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: unknown) => ({ + data: + atom === state.sessionAtom + ? state.session + : atom === state.searchAtom + ? { entries: [{ path: "src/index.ts", kind: "file" }] } + : null, + error: atom === state.sessionAtom ? state.sessionError : null, + isPending: atom === state.sessionAtom && state.session === null && state.sessionError === null, + refresh: vi.fn(), + }), +})); + +import { useProjectPathSearch } from "./queries"; + +const useComposerPathSearch = (input: Parameters[0]) => + useProjectPathSearch(input, 20); + +const target = { + environmentId: EnvironmentId.make("test-environment"), + cwd: "/repo", + query: "src", +}; + +beforeEach(() => { + state.session = null; + state.sessionError = null; + state.phase = "connected"; +}); + +it("keeps a file search pending until its grant loads, then shows matches", () => { + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: null, + isPending: true, + }); + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [{ path: "src/index.ts", kind: "file" }], + error: null, + isPending: false, + }); +}); + +it("shows a confirmed denial and a connection failure separately", () => { + state.session = { authenticated: true, scopes: [] }; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: "This connection cannot search host files.", + isPending: false, + }); + state.session = null; + state.phase = "offline"; + expect(useComposerPathSearch(target)).toMatchObject({ + entries: [], + error: "This environment is not connected.", + isPending: false, + }); +}); + +it("leaves an inactive search idle while its grant loads", () => { + expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({ + entries: [], + error: null, + isPending: false, + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index f8ff1147615b..e00a81ad6bef 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -1,5 +1,6 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "./session"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +import { environmentSession } from "./session"; +import { useEnvironmentPresentation } from "./presentation"; import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffTarget, @@ -272,14 +273,25 @@ export function useProjectPathSearch( [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); - const canReadFiles = useEnvironmentScope(debouncedTarget.environmentId, AuthFilesystemReadScope); - const result = useEnvironmentQuery( - canReadFiles && - debouncedTarget.environmentId !== null && - debouncedTarget.cwd !== null && - debouncedTarget.query !== null && - (allowEmptyQuery || debouncedTarget.query.length > 0) - ? projectEnvironment.searchEntries({ + const fileAccessSession = useEnvironmentQuery( + debouncedTarget.environmentId === null + ? null + : environmentSession.sessionStateAtom(debouncedTarget.environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; + const searchTarget = + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) + ? { environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, @@ -288,15 +300,24 @@ export function useProjectPathSearch( ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}), }, - }) - : null, + } + : null; + const result = useEnvironmentQuery( + canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, ); + const hasTarget = searchTarget !== null; return { entries: result.data?.entries ?? [], - error: canReadFiles ? result.error : "This connection cannot search host files.", + error: + !hasTarget || fileAccess.isPending + ? null + : canReadFiles + ? result.error + : (fileAccess.error ?? "This connection cannot search host files."), isPending: - !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || + (hasTarget && (fileAccess.isPending || result.isPending)), searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; From f0358ecb952cc15a16a46ae47e3bbf67b9f9e138 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:04:57 -0700 Subject: [PATCH 16/29] fix(clients): wait for file grants before failing assets --- apps/mobile/src/state/assets.test.ts | 107 ++++++++++++++++++ apps/mobile/src/state/assets.ts | 52 ++++----- apps/web/src/assets/assetUrls.test.ts | 100 ++++++++++++++++ apps/web/src/assets/assetUrls.ts | 26 +++-- .../components/ChatMarkdown.assets.test.tsx | 94 +++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 30 +---- 6 files changed, 340 insertions(+), 69 deletions(-) create mode 100644 apps/mobile/src/state/assets.test.ts create mode 100644 apps/web/src/assets/assetUrls.test.ts create mode 100644 apps/web/src/components/ChatMarkdown.assets.test.tsx diff --git a/apps/mobile/src/state/assets.test.ts b/apps/mobile/src/state/assets.test.ts new file mode 100644 index 000000000000..811dedf349f4 --- /dev/null +++ b/apps/mobile/src/state/assets.test.ts @@ -0,0 +1,107 @@ +import { + AuthFilesystemReadScope, + EnvironmentAuthorizationError, + EnvironmentId, + ThreadId, + type AuthSessionState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + session: null as Pick | null, + phase: "connected" as "connected" | "offline", + assetAtom: {}, + mint: vi.fn(), + assetQuery: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: A) => callback })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + atom === state.assetAtom + ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) + : AsyncResult.initial(false), +})); +vi.mock("./session", () => ({ + environmentSession: { sessionStateAtom: () => ({}) }, + usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), +})); +vi.mock("./presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: state.phase, error: null } }, + }), +})); +vi.mock("./query", () => ({ + useEnvironmentQuery: () => ({ data: state.session, error: null }), +})); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("@t3tools/client-runtime/state/assets", async (importOriginal) => ({ + ...(await importOriginal()), + createAssetEnvironmentAtoms: () => ({ createUrl: state.assetQuery }), +})); +vi.mock("./use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); + +import { useRefreshAssetUrl, useAssetUrlState } from "./assets"; + +const environmentId = EnvironmentId.make("asset-environment"); +const threadId = ThreadId.make("asset-thread"); +const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; + +beforeEach(() => { + state.session = null; + state.phase = "connected"; + state.assetQuery.mockReset().mockReturnValue(state.assetAtom); + state.mint + .mockReset() + .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); +}); + +it.each(["workspace-file", "media-file"] as const)( + "keeps %s loading until its file grant resolves", + (_tag) => { + expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); + expect(state.assetQuery).not.toHaveBeenCalled(); + + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ + _tag: "Success", + url: "https://host.test/api/assets/image.png", + }); + }, +); + +it("hides host assets with a denied grant while preserving attachments", () => { + state.session = { authenticated: true, scopes: [] }; + expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure", reason: "failed" }); + expect(state.assetQuery).not.toHaveBeenCalled(); + expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ + _tag: "Success", + url: "https://host.test/api/assets/image.png", + }); +}); + +it("stops waiting for an unresolved grant when the connection is offline", () => { + state.phase = "offline"; + expect(useAssetUrlState(environmentId, resource)).toEqual({ + _tag: "Failure", + reason: "disconnected", + }); + expect(state.assetQuery).not.toHaveBeenCalled(); +}); + +it("lets the server authorize an explicit refresh before the client grant loads", async () => { + await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBe( + "https://host.test/api/assets/image.png", + ); + expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); + + const denied = new EnvironmentAuthorizationError({ + message: "This connection cannot read host files.", + requiredScope: AuthFilesystemReadScope, + }); + state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); + await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBeNull(); +}); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 681c8ae4a3cc..f0e649b5ca4f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,59 +1,52 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { - type EnvironmentConnectionPhase, - presentConnectionState, -} from "@t3tools/client-runtime/connection"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { assetUrlStateFromResult, createAssetEnvironmentAtoms, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; -import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection, useEnvironmentScope, readEnvironmentScope } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; +import { useEnvironmentPresentation } from "./presentation"; +import { useEnvironmentQuery } from "./query"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); -const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( - Atom.withLabel("mobile-asset-connection-state:empty"), -); - -function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase { - const state = useAtomValue( - environmentId === null - ? EMPTY_CONNECTION_STATE_ATOM - : environmentCatalog.stateAtom(environmentId), - ); - const value = Option.getOrNull(AsyncResult.value(state)); - return value === null ? "available" : presentConnectionState(value).phase; -} - export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); const canReadResource = - canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); + fileAccess.canReadFiles || + (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); - const connectionPhase = useConnectionPhase(environmentId); + const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available"; const result = useAtomValue( !canReadResource || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); const shared = !canReadResource - ? { _tag: "Failure" as const } + ? fileAccess.isPending + ? { _tag: "Loading" as const } + : { _tag: "Failure" as const } : assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, @@ -88,11 +81,6 @@ export function useRefreshAssetUrl( }); return useCallback(async () => { if (environmentId === null || resource === null || httpBaseUrl === null) return null; - if ( - (resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) - ) - return null; const state = assetUrlStateFromResult( await createUrl({ environmentId, input: { resource } }), httpBaseUrl, diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts new file mode 100644 index 000000000000..f32dc4ef5dba --- /dev/null +++ b/apps/web/src/assets/assetUrls.test.ts @@ -0,0 +1,100 @@ +import { + AuthFilesystemReadScope, + EnvironmentAuthorizationError, + EnvironmentId, + ThreadId, + type AuthSessionState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + session: null as Pick | null, + phase: "connected" as "connected" | "offline", + assetAtom: {}, + mint: vi.fn(), + assetQuery: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: A) => callback })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => + atom === state.assetAtom + ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) + : AsyncResult.initial(false), +})); +vi.mock("~/state/session", () => ({ + environmentSession: { sessionStateAtom: () => ({}) }, + usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), +})); +vi.mock("~/state/presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: state.phase, error: null } }, + }), +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: () => ({ data: state.session, error: null }), +})); +vi.mock("~/state/assets", () => ({ + assetEnvironment: { createUrl: state.assetQuery }, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); + +import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls"; + +const environmentId = EnvironmentId.make("asset-environment"); +const threadId = ThreadId.make("asset-thread"); +const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; + +beforeEach(() => { + state.session = null; + state.phase = "connected"; + state.assetQuery.mockReset().mockReturnValue(state.assetAtom); + state.mint + .mockReset() + .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); +}); + +it.each(["workspace-file", "media-file"] as const)( + "keeps %s loading until its file grant resolves", + (_tag) => { + expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); + expect(state.assetQuery).not.toHaveBeenCalled(); + + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ + _tag: "Success", + url: "https://host.test/api/assets/image.png", + }); + }, +); + +it("hides host assets with a denied grant while preserving attachments", () => { + state.session = { authenticated: true, scopes: [] }; + expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); + expect(state.assetQuery).not.toHaveBeenCalled(); + expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ + _tag: "Success", + url: "https://host.test/api/assets/image.png", + }); +}); + +it("stops waiting for an unresolved grant when the connection is offline", () => { + state.phase = "offline"; + expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); + expect(state.assetQuery).not.toHaveBeenCalled(); +}); + +it("lets the server authorize an explicit refresh before the client grant loads", async () => { + await expect(useAssetUrlRefresh(environmentId, resource)()).resolves.toBeUndefined(); + expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); + + const denied = new EnvironmentAuthorizationError({ + message: "This connection cannot read host files.", + requiredScope: AuthFilesystemReadScope, + }); + state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); + await expect(useAssetUrlRefresh(environmentId, resource)()).rejects.toBe(denied); +}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index c0a5167efb7a..b31814aa43d8 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -7,12 +7,15 @@ import { resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; -import { usePreparedConnection, useEnvironmentScope, readEnvironmentScope } from "~/state/session"; +import { environmentSession, usePreparedConnection, useEnvironmentScope } from "~/state/session"; +import { useEnvironmentPresentation } from "~/state/presentation"; +import { useEnvironmentQuery } from "~/state/query"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; @@ -21,16 +24,26 @@ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); const canReadResource = - canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); + fileAccess.canReadFiles || + (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( !canReadResource || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - if (!canReadResource) return { _tag: "Failure" }; + if (!canReadResource) return { _tag: fileAccess.isPending ? "Loading" : "Failure" }; return assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, @@ -55,11 +68,6 @@ export function useAssetUrlRefresh( }); return useCallback(async () => { if (environmentId === null || resource === null) return; - if ( - (resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) - ) - return; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); }, [environmentId, resource, refresh]); diff --git a/apps/web/src/components/ChatMarkdown.assets.test.tsx b/apps/web/src/components/ChatMarkdown.assets.test.tsx new file mode 100644 index 000000000000..42b7fdc000ef --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.assets.test.tsx @@ -0,0 +1,94 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, type ComponentProps, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { expect, it, vi } from "vite-plus/test"; + +const mint = vi.hoisted(() => vi.fn()); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useSettings", async (importOriginal) => { + const actual = await importOriginal(); + const settings = actual.getClientSettings(); + return { + ...actual, + useClientSettings: (select: (value: typeof settings) => unknown) => select(settings), + }; +}); +vi.mock("./ui/tooltip", async () => { + const { cloneElement, isValidElement } = await import("react"); + return { + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger({ + render, + children, + }: ComponentProps) { + if (!isValidElement(render)) return <>{children}; + return children === undefined ? render : cloneElement(render, undefined, children); + }, + TooltipPopup: () => null, + }; +}); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => mint })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + readEnvironmentScope: () => false, + usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), +})); +vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [] })); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown from "./ChatMarkdown"; + +it("opens host media through server authorization before the client grant loads", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mint.mockResolvedValue( + AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }), + ); + const onImageExpand = vi.fn(); + const threadRef = { + environmentId: EnvironmentId.make("media-environment"), + threadId: ThreadId.make("media-thread"), + }; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => { + const link = renderer!.root + .findAllByType("a") + .find((node) => node.props.href === "/tmp/image.png"); + expect(link).toBeDefined(); + link!.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + }); + expect(onImageExpand).toHaveBeenCalledWith( + expect.objectContaining({ + images: [expect.objectContaining({ src: "https://host.test/api/assets/image.png" })], + }), + ); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 580cbcba3c86..89f66134b140 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,8 +1,4 @@ -import { - AuthFilesystemReadScope, - AuthOrchestrationOperateScope, - EnvironmentAuthorizationError, -} from "@t3tools/contracts"; +import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -2153,32 +2149,10 @@ function useChatMarkdownState({ mediaRequestId.current += 1; }; }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); - const loadAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); - const createAssetUrl = useCallback( - (input) => { - const resource = input.input.resource; - if ( - (resource._tag === "workspace-file" || resource._tag === "media-file") && - !readEnvironmentScope(input.environmentId, AuthFilesystemReadScope) - ) { - return Promise.resolve( - AsyncResult.failure( - Cause.fail( - new EnvironmentAuthorizationError({ - message: "This connection cannot read host files.", - requiredScope: AuthFilesystemReadScope, - }), - ), - ), - ); - } - return loadAssetUrl(input); - }, - [loadAssetUrl], - ); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); From 1960a43fb79d544381602c7c2aa81fa702579f85 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:09:03 -0700 Subject: [PATCH 17/29] test(web): isolate pending asset grants from preview controls --- apps/web/src/components/ChatMarkdown.assets.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatMarkdown.assets.test.tsx b/apps/web/src/components/ChatMarkdown.assets.test.tsx index 42b7fdc000ef..5608b945db0a 100644 --- a/apps/web/src/components/ChatMarkdown.assets.test.tsx +++ b/apps/web/src/components/ChatMarkdown.assets.test.tsx @@ -34,6 +34,7 @@ vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("../state/session", async (importOriginal) => ({ ...(await importOriginal()), readEnvironmentScope: () => false, + useEnvironmentScope: () => false, usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), })); vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [] })); From c02b8abe6daac15a9c057f269efb9090a9d9edb1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:19:49 -0700 Subject: [PATCH 18/29] fix(mobile): preserve review selection while grants load --- .../features/review/useReviewSections.test.ts | 112 ++++++++++++++---- .../src/features/review/useReviewSections.ts | 58 +++++---- 2 files changed, 129 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/review/useReviewSections.test.ts b/apps/mobile/src/features/review/useReviewSections.test.ts index b31a2a63709d..594d464fb45b 100644 --- a/apps/mobile/src/features/review/useReviewSections.test.ts +++ b/apps/mobile/src/features/review/useReviewSections.test.ts @@ -1,25 +1,35 @@ import { + AuthFilesystemReadScope, CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId, + type AuthSessionState, type OrchestrationCheckpointSummary, } from "@t3tools/contracts"; -import { expect, it, vi } from "vite-plus/test"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ - canReadFiles: true, + session: null as Pick | null, + sessionAtom: {}, + effects: [] as Array<() => void>, checkpoints: [] as ReadonlyArray, })); vi.mock("react", () => ({ useCallback: (callback: A) => callback, - useEffect: () => {}, + useEffect: (effect: () => void) => state.effects.push(effect), useMemo: (factory: () => A) => factory(), })); vi.mock("../../state/session", () => ({ - useEnvironmentScope: () => state.canReadFiles, + environmentSession: { sessionStateAtom: () => state.sessionAtom }, +})); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: "connected", error: null } }, + }), })); vi.mock("../../state/use-thread-detail", () => ({ useSelectedThreadDetail: () => ({ checkpoints: state.checkpoints }), @@ -28,7 +38,12 @@ vi.mock("../../state/use-selected-thread-worktree", () => ({ useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }), })); vi.mock("../../state/query", () => ({ - useEnvironmentQuery: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), + useEnvironmentQuery: (atom: unknown) => ({ + data: atom === state.sessionAtom ? state.session : null, + error: null, + isPending: atom === state.sessionAtom && state.session === null, + refresh: vi.fn(), + }), })); vi.mock("../../state/queries", () => ({ useCheckpointDiff: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), @@ -44,10 +59,13 @@ vi.mock("./reviewState", () => ({ setReviewTurnDiffLoading: vi.fn(), })); -import type { ReviewCacheForThread } from "./reviewState"; +import { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState"; import { useReviewSections } from "./useReviewSections"; -it("hides cached local diffs after file access is lost while retaining checkpoint diffs", () => { +beforeEach(() => { + vi.clearAllMocks(); + state.effects = []; + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; state.checkpoints = [ { turnId: TurnId.make("turn-1"), @@ -59,14 +77,19 @@ it("hides cached local diffs after file access is lost while retaining checkpoin completedAt: "2026-04-01T00:00:00.000Z", }, ]; - const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts"; - const localDiff = "diff --git a/local.ts b/local.ts"; - const reviewCache: ReviewCacheForThread = { +}); + +const checkpointDiff = "diff --git a/checkpoint.ts b/checkpoint.ts"; +const localDiff = "diff --git a/local.ts b/local.ts"; +function makeReviewCache( + kind: "working-tree" | "branch-range" = "working-tree", +): ReviewCacheForThread { + return { threadKey: "environment:thread", gitSections: [ { - id: "working-tree", - kind: "working-tree", + id: kind, + kind, title: "Dirty worktree", baseRef: "HEAD", headRef: null, @@ -76,26 +99,75 @@ it("hides cached local diffs after file access is lost while retaining checkpoin }, ], turnDiffById: { "turn:1": checkpointDiff }, - selectedSectionId: "git:working-tree", + selectedSectionId: `git:${kind}`, asyncState: { loadingTurnIds: {}, error: null }, expandedFileIdsBySection: {}, revealedLargeFileIdsBySection: {}, viewedFileIdsBySection: {}, }; - const input = { +} + +function makeInput(reviewCache = makeReviewCache()) { + return { environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), reviewCache, }; +} + +function renderSections(input: ReturnType) { + const result = useReviewSections(input); + const effects = state.effects.splice(0); + effects.forEach((effect) => effect()); + return result; +} - state.canReadFiles = true; - expect(useReviewSections(input).selectedSection?.diff).toBe(localDiff); +it("hides cached local diffs after file access is lost while retaining checkpoint diffs", () => { + const input = makeInput(); + expect(renderSections(input).selectedSection?.diff).toBe(localDiff); - state.canReadFiles = false; - const denied = useReviewSections(input); + state.session = { authenticated: true, scopes: [] }; + const denied = renderSections(input); expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]); expect(denied.selectedSection?.diff).toBe(checkpointDiff); - state.canReadFiles = true; - expect(useReviewSections(input).selectedSection?.diff).toBe(localDiff); + expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1"); +}); + +it.each(["working-tree", "branch-range"] as const)( + "preserves a cached %s selection while an expired grant reloads", + (kind) => { + const input = makeInput(makeReviewCache(kind)); + expect(renderSections(input).selectedSection?.diff).toBe(localDiff); + + state.session = null; + const pending = renderSections(input); + expect(pending.selectedSection).toEqual( + expect.objectContaining({ id: `git:${kind}`, diff: null, isLoading: true }), + ); + expect(pending.loadingGitDiffs).toBe(true); + expect(pending.reviewSections.find((section) => section.id === "turn:1")?.diff).toBe( + checkpointDiff, + ); + expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); + + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(renderSections(input).selectedSection).toEqual( + expect.objectContaining({ id: `git:${kind}`, diff: localDiff, isLoading: false }), + ); + expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); + }, +); + +it("falls back to a checkpoint when a pending grant resolves without file access", () => { + const input = makeInput(); + state.session = null; + renderSections(input); + expect(setReviewSelectedSectionId).not.toHaveBeenCalled(); + + state.session = { authenticated: true, scopes: [] }; + const denied = renderSections(input); + expect(denied.reviewSections.map((section) => section.id)).toEqual(["turn:1"]); + expect(denied.selectedSection?.diff).toBe(checkpointDiff); + expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1"); }); diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index be7837124767..497f45a0bf29 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,11 +1,12 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; -import { useEnvironmentScope } from "../../state/session"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +import { environmentSession } from "../../state/session"; import { useCallback, useEffect, useMemo } from "react"; import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; import { useCheckpointDiff } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { reviewEnvironment } from "../../state/review"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; @@ -32,7 +33,17 @@ export function useReviewSections(input: { }) { const { environmentId, reviewCache, threadId } = input; const enabled = input.enabled ?? true; - const canReadFiles = useEnvironmentScope(environmentId ?? null, AuthFilesystemReadScope); + const fileAccessSession = useEnvironmentQuery( + environmentId === undefined ? null : environmentSession.sessionStateAtom(environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(environmentId ?? null); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const diffPreview = useEnvironmentQuery( @@ -65,24 +76,29 @@ export function useReviewSections(input: { ) as Record, [readyCheckpoints], ); - const reviewSections = useMemo( - () => - buildReviewSectionItems({ - checkpoints: readyCheckpoints, - gitSections: canReadFiles ? reviewCache.gitSections : [], - turnDiffById: reviewCache.turnDiffById, - loadingTurnIds, - loadingGitSections: diffPreview.isPending, - }), - [ - canReadFiles, - diffPreview.isPending, + const reviewSections = useMemo(() => { + const sections = buildReviewSectionItems({ + checkpoints: readyCheckpoints, + gitSections: canReadFiles || fileAccess.isPending ? reviewCache.gitSections : [], + turnDiffById: reviewCache.turnDiffById, loadingTurnIds, - readyCheckpoints, - reviewCache.gitSections, - reviewCache.turnDiffById, - ], - ); + loadingGitSections: fileAccess.isPending || diffPreview.isPending, + }); + // Keep the selected section while its grant loads, without displaying cached host files. + return fileAccess.isPending + ? sections.map((section) => + section.kind === "turn" ? section : { ...section, diff: null, isLoading: true }, + ) + : sections; + }, [ + canReadFiles, + diffPreview.isPending, + fileAccess.isPending, + loadingTurnIds, + readyCheckpoints, + reviewCache.gitSections, + reviewCache.turnDiffById, + ]); const selectedSection = useMemo( () => reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? @@ -177,7 +193,7 @@ export function useReviewSections(input: { return { error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, - loadingGitDiffs: diffPreview.isPending, + loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, loadingTurnIds, reviewSections, selectedSection, From c13fbf22b58a0e92f5328c5fd308f36d960aa4ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:45:36 -0700 Subject: [PATCH 19/29] fix(mobile): report access failures for unavailable reviews --- .../src/features/review/ReviewSheet.tsx | 14 ++++++----- .../features/review/useReviewSections.test.ts | 23 ++++++++++++++++++- .../src/features/review/useReviewSections.ts | 6 ++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 80ebe1157d92..a4316f88598d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -840,12 +840,14 @@ export function ReviewSheet(props: ReviewSheetProps) { > {listHeader} {!selectedSection ? ( - - No review diffs - - This thread has no ready turn diffs and the worktree diff is empty. - - + error ? null : ( + + No review diffs + + This thread has no ready turn diffs and the worktree diff is empty. + + + ) ) : selectedSection.isLoading && selectedSection.diff === null ? ( diff --git a/apps/mobile/src/features/review/useReviewSections.test.ts b/apps/mobile/src/features/review/useReviewSections.test.ts index 594d464fb45b..c7852c93d8c1 100644 --- a/apps/mobile/src/features/review/useReviewSections.test.ts +++ b/apps/mobile/src/features/review/useReviewSections.test.ts @@ -12,6 +12,7 @@ import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ session: null as Pick | null, + sessionError: null as string | null, sessionAtom: {}, effects: [] as Array<() => void>, checkpoints: [] as ReadonlyArray, @@ -40,7 +41,7 @@ vi.mock("../../state/use-selected-thread-worktree", () => ({ vi.mock("../../state/query", () => ({ useEnvironmentQuery: (atom: unknown) => ({ data: atom === state.sessionAtom ? state.session : null, - error: null, + error: atom === state.sessionAtom ? state.sessionError : null, isPending: atom === state.sessionAtom && state.session === null, refresh: vi.fn(), }), @@ -66,6 +67,7 @@ beforeEach(() => { vi.clearAllMocks(); state.effects = []; state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + state.sessionError = null; state.checkpoints = [ { turnId: TurnId.make("turn-1"), @@ -171,3 +173,22 @@ it("falls back to a checkpoint when a pending grant resolves without file access expect(denied.selectedSection?.diff).toBe(checkpointDiff); expect(setReviewSelectedSectionId).toHaveBeenCalledWith("environment:thread", "turn:1"); }); + +it("reports a failed access check when no checkpoint can replace the local review", () => { + state.checkpoints = []; + const input = makeInput(); + expect(renderSections(input).selectedSection?.diff).toBe(localDiff); + + state.sessionError = "The session request timed out."; + const unavailable = renderSections(input); + expect(unavailable.selectedSection).toBeNull(); + expect(unavailable.reviewSections).toEqual([]); + expect(unavailable.error).toBe(state.sessionError); +}); + +it("keeps a cached checkpoint available when the filesystem access check fails", () => { + state.sessionError = "The session request timed out."; + const checkpoint = renderSections(makeInput()); + expect(checkpoint.selectedSection?.diff).toBe(checkpointDiff); + expect(checkpoint.error).toBeNull(); +}); diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 497f45a0bf29..af6a820e6bfc 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -192,7 +192,11 @@ export function useReviewSections(input: { ); return { - error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, + error: + diffPreview.error ?? + activeTurnDiff.error ?? + reviewCache.asyncState.error ?? + (selectedSection === null ? fileAccess.error : null), loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, loadingTurnIds, reviewSections, From db6a3a7fd6cfe6c6007e92274f68709e73284b75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 18:20:45 -0700 Subject: [PATCH 20/29] fix(web): keep filesystem access checks pending --- apps/web/src/components/CommandPalette.tsx | 33 +++- .../files/projectFilesQueryState.test.tsx | 174 +++++++++++++++++- .../files/projectFilesQueryState.ts | 29 ++- apps/web/src/state/filesystem.ts | 22 ++- 4 files changed, 235 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 96c0d8c29e83..fa94d82c1be4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -80,7 +80,7 @@ import { useClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; -import { filesystemEnvironment } from "../state/filesystem"; +import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; @@ -992,13 +992,14 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const canBrowseFiles = useEnvironmentScope(browseEnvironmentId, AuthFilesystemReadScope); + const browseAccess = useFilesystemReadAccess(browseEnvironmentId); + const hasBrowseTarget = + isBrowsing && + browsePath.directoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject; const browseQuery = useEnvironmentQuery( - canBrowseFiles && - isBrowsing && - browsePath.directoryPath.length > 0 && - browseEnvironmentId !== null && - !relativePathNeedsActiveProject + browseAccess.canReadFiles && hasBrowseTarget ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { @@ -1009,7 +1010,13 @@ function OpenCommandPaletteDialog(props: { : null, ); const browseResult = browseQuery.data; - const isBrowsePending = browseQuery.isPending; + const isBrowsePending = hasBrowseTarget && (browseAccess.isPending || browseQuery.isPending); + const browseError = + !hasBrowseTarget || browseAccess.isPending + ? null + : browseAccess.canReadFiles + ? browseQuery.error + : (browseAccess.error ?? "This connection cannot browse host folders."); const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => @@ -2251,6 +2258,7 @@ function OpenCommandPaletteDialog(props: { const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && + browseError === null && query.trim().length > 0 && !hasHighlightedBrowseItem && (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); @@ -2675,6 +2683,15 @@ function OpenCommandPaletteDialog(props: {
) : null} + {browseError ? ( +
+ {browseError} +
+ ) : isBrowsePending && browseResult === null ? ( +
+ Loading folders... +
+ ) : null} ({ canReadFiles: true })); +const authorizationMocks = vi.hoisted(() => ({ + sessionAtom: null as Atom.Atom< + AsyncResult.AsyncResult, Error> + > | null, + phase: "connected" as "connected" | "offline", +})); vi.mock("~/state/session", () => ({ - useEnvironmentScope: () => authorizationMocks.canReadFiles, + environmentSession: { sessionStateAtom: () => authorizationMocks.sessionAtom }, +})); + +vi.mock("~/state/presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: authorizationMocks.phase, error: null } }, + }), })); const projectMocks = vi.hoisted(() => ({ @@ -68,6 +83,7 @@ vi.mock("react", async (importOriginal) => { ...actual, useCallback: reactHooks.useCallback, useEffect: reactHooks.useEffect, + useMemo: (factory: () => A) => factory(), useRef: reactHooks.useRef, }; }); @@ -81,6 +97,7 @@ vi.mock("~/state/queries", () => ({ })); import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts"; import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-1"); @@ -117,7 +134,10 @@ async function flushEffects(): Promise { describe("project query refresh", () => { beforeEach(() => { - authorizationMocks.canReadFiles = true; + authorizationMocks.sessionAtom = Atom.make( + AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), + ); + authorizationMocks.phase = "connected"; projectMocks.listEntries.mockReset(); projectMocks.optimisticFile.mockReset(); projectMocks.readFile.mockReset(); @@ -125,7 +145,9 @@ describe("project query refresh", () => { }); it("does not query or expose optimistic file contents without read permission", () => { - authorizationMocks.canReadFiles = false; + authorizationMocks.sessionAtom = Atom.make( + AsyncResult.success({ authenticated: true, scopes: [] }), + ); const registry = AtomRegistry.make(); atomHooks.registry = registry; projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); @@ -134,9 +156,151 @@ describe("project query refresh", () => { expect(projectMocks.readFile).not.toHaveBeenCalled(); expect(query.data).toBeNull(); expect(query.error).toBe("This connection cannot read host files."); + expect(query.isPending).toBe(false); const entries = useProjectEntriesQuery(environmentId, "/repo"); expect(projectMocks.listEntries).not.toHaveBeenCalled(); expect(entries.data).toBeNull(); + expect(entries.error).toBe("This connection cannot read host files."); + expect(entries.isPending).toBe(false); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("keeps t3.json and the file tree loading until the file grant arrives", () => { + authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); + const registry = AtomRegistry.make(); + atomHooks.registry = registry; + const config = { + defaultThreadEnvMode: "worktree", + scripts: [{ name: "Test", command: "vp test" }], + }; + projectMocks.readFile.mockReturnValue( + Atom.make(AsyncResult.success(file(JSON.stringify(config)))), + ); + projectMocks.listEntries.mockReturnValue( + Atom.make(AsyncResult.success(projectEntries(["t3.json"]))), + ); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + try { + expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ + data: null, + error: null, + isPending: true, + }); + expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ + data: null, + error: null, + isPending: true, + }); + expect(useT3ProjectFileState(environmentId, "/repo").status).toBe("loading"); + expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(projectMocks.listEntries).not.toHaveBeenCalled(); + + authorizationMocks.sessionAtom = Atom.make( + AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), + ); + expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ + status: "valid", + file: config, + scripts: config.scripts, + }); + expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ + data: projectEntries(["t3.json"]), + error: null, + isPending: false, + }); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }); + + it.each(["connected", "offline"] as const)( + "preserves cached t3.json defaults and scripts during a granted refresh while %s", + (phase) => { + authorizationMocks.phase = phase; + authorizationMocks.sessionAtom = Atom.make( + AsyncResult.success( + { authenticated: true, scopes: [AuthFilesystemReadScope] }, + { waiting: true }, + ), + ); + const registry = AtomRegistry.make(); + atomHooks.registry = registry; + const config = { + defaultThreadEnvMode: "worktree", + scripts: [{ name: "Test", command: "vp test" }], + }; + projectMocks.readFile.mockReturnValue( + Atom.make(AsyncResult.success(file(JSON.stringify(config)), { waiting: true })), + ); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + try { + expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ + error: null, + isPending: true, + }); + expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ + status: "valid", + file: config, + scripts: config.scripts, + }); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }, + ); + + it.each([ + { phase: "connected", sessionError: "The session request timed out." }, + { phase: "offline", sessionError: null }, + ] as const)( + "reports unavailable file access for $phase connections", + ({ phase, sessionError }) => { + authorizationMocks.phase = phase; + authorizationMocks.sessionAtom = Atom.make( + sessionError === null + ? AsyncResult.initial() + : AsyncResult.failure(Cause.fail(new Error(sessionError))), + ); + const registry = AtomRegistry.make(); + atomHooks.registry = registry; + projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); + try { + const unavailable = { + data: null, + error: sessionError ?? "This environment is not connected.", + isPending: false, + }; + expect(useProjectFileQuery(environmentId, "/repo", "src/preview.ts")).toMatchObject( + unavailable, + ); + expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject(unavailable); + expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(projectMocks.listEntries).not.toHaveBeenCalled(); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }, + ); + + it("leaves disabled file queries idle while the file grant loads", () => { + authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); + const registry = AtomRegistry.make(); + atomHooks.registry = registry; + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + try { + expect(useProjectFileQuery(environmentId, "/repo", "t3.json", false)).toMatchObject({ + data: null, + error: null, + isPending: false, + }); + expect(useT3ProjectFileState(environmentId, null).status).toBe("missing"); + expect(projectMocks.readFile).not.toHaveBeenCalled(); } finally { registry.dispose(); atomHooks.registry = null; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 2a9bcd887a64..215640b244b1 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,7 +4,6 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; -import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -15,9 +14,9 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useFilesystemReadAccess } from "~/state/filesystem"; import { projectEnvironment } from "~/state/projects"; import { useProjectPathSearch } from "~/state/queries"; -import { useEnvironmentScope } from "~/state/session"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -159,7 +158,8 @@ export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, ): ProjectQueryState { - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccess = useFilesystemReadAccess(environmentId); + const { canReadFiles } = fileAccess; const atom = canReadFiles ? getProjectEntriesQueryAtom(environmentId, cwd) : EMPTY_PROJECT_ENTRIES_QUERY_ATOM; @@ -168,8 +168,12 @@ export function useProjectEntriesQuery( const refresh = useCallback(() => refreshAtom(), [refreshAtom]); return { data: Option.getOrNull(AsyncResult.value(result)), - error: canReadFiles ? errorMessage(result) : "This connection cannot read host files.", - isPending: result.waiting, + error: fileAccess.isPending + ? null + : canReadFiles + ? errorMessage(result) + : (fileAccess.error ?? "This connection cannot read host files."), + isPending: fileAccess.isPending || result.waiting, refresh, }; } @@ -215,12 +219,14 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const fileAccess = useFilesystemReadAccess(environmentId); + const { canReadFiles } = fileAccess; const isMedia = relativePath !== null && (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const isQueryEnabled = enabled && !isMedia; const atom = - canReadFiles && enabled && !isMedia + canReadFiles && isQueryEnabled ? getProjectFileQueryAtom(environmentId, cwd, relativePath) : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); @@ -234,8 +240,13 @@ export function useProjectFileQuery( return { data: canReadFiles ? (optimisticFile?.data ?? data) : null, - error: canReadFiles ? errorMessage(result) : "This connection cannot read host files.", - isPending: result.waiting, + error: + !isQueryEnabled || fileAccess.isPending + ? null + : canReadFiles + ? errorMessage(result) + : (fileAccess.error ?? "This connection cannot read host files."), + isPending: isQueryEnabled && (fileAccess.isPending || result.waiting), refresh, }; } diff --git a/apps/web/src/state/filesystem.ts b/apps/web/src/state/filesystem.ts index 19d5b53c4e09..c2a5d1212f20 100644 --- a/apps/web/src/state/filesystem.ts +++ b/apps/web/src/state/filesystem.ts @@ -1,5 +1,25 @@ -import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; +import { + createFilesystemEnvironmentAtoms, + resolveFilesystemReadAccess, +} from "@t3tools/client-runtime/state/filesystem"; +import type { EnvironmentId } from "@t3tools/contracts"; import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironmentPresentation } from "./presentation"; +import { useEnvironmentQuery } from "./query"; +import { environmentSession } from "./session"; export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); + +export function useFilesystemReadAccess(environmentId: EnvironmentId | null) { + const session = useEnvironmentQuery( + environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), + ); + const environment = useEnvironmentPresentation(environmentId); + return resolveFilesystemReadAccess({ + isCatalogReady: environment.isReady, + connection: environment.presentation?.connection ?? null, + session: session.data, + sessionError: session.error, + }); +} From 43b28aa45a74b578e12aa712ff0f6dce27b7bc71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 18:32:36 -0700 Subject: [PATCH 21/29] fix(web): retain new-folder prompts after listing errors --- apps/web/src/components/CommandPalette.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fa94d82c1be4..696bf3dc1564 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1011,12 +1011,10 @@ function OpenCommandPaletteDialog(props: { ); const browseResult = browseQuery.data; const isBrowsePending = hasBrowseTarget && (browseAccess.isPending || browseQuery.isPending); - const browseError = - !hasBrowseTarget || browseAccess.isPending - ? null - : browseAccess.canReadFiles - ? browseQuery.error - : (browseAccess.error ?? "This connection cannot browse host folders."); + const browseAccessError = + hasBrowseTarget && !browseAccess.isPending && !browseAccess.canReadFiles + ? (browseAccess.error ?? "This connection cannot browse host folders.") + : null; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => @@ -2258,7 +2256,7 @@ function OpenCommandPaletteDialog(props: { const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && - browseError === null && + browseAccessError === null && query.trim().length > 0 && !hasHighlightedBrowseItem && (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); @@ -2683,9 +2681,9 @@ function OpenCommandPaletteDialog(props: { ) : null} - {browseError ? ( + {browseAccessError ? (
- {browseError} + {browseAccessError}
) : isBrowsePending && browseResult === null ? (
From 0e1f1a895eac4ba3f2e79848d2a0d9449248415c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 19:29:06 -0700 Subject: [PATCH 22/29] fix(mobile): report denied local review access --- .../src/features/review/useReviewSections.test.ts | 13 +++++++++++++ .../mobile/src/features/review/useReviewSections.ts | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/review/useReviewSections.test.ts b/apps/mobile/src/features/review/useReviewSections.test.ts index c7852c93d8c1..c7791c351cfd 100644 --- a/apps/mobile/src/features/review/useReviewSections.test.ts +++ b/apps/mobile/src/features/review/useReviewSections.test.ts @@ -186,6 +186,19 @@ it("reports a failed access check when no checkpoint can replace the local revie expect(unavailable.error).toBe(state.sessionError); }); +it("reports denied file access when no checkpoint can replace the local review", () => { + state.checkpoints = []; + const input = makeInput(); + expect(renderSections(input).selectedSection?.diff).toBe(localDiff); + + state.session = { authenticated: true, scopes: [] }; + const denied = renderSections(input); + expect(denied.selectedSection).toBeNull(); + expect(denied.reviewSections).toEqual([]); + expect(denied.loadingGitDiffs).toBe(false); + expect(denied.error).toBe("This connection cannot read local diffs."); +}); + it("keeps a cached checkpoint available when the filesystem access check fails", () => { state.sessionError = "The session request timed out."; const checkpoint = renderSections(makeInput()); diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index af6a820e6bfc..9c984539b3b7 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -196,7 +196,9 @@ export function useReviewSections(input: { diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error ?? - (selectedSection === null ? fileAccess.error : null), + (selectedSection === null && !fileAccess.isPending && !canReadFiles + ? (fileAccess.error ?? "This connection cannot read local diffs.") + : null), loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, loadingTurnIds, reviewSections, From 8e94a38297a167937b191b929336521da8bb0080 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:16:14 -0700 Subject: [PATCH 23/29] fix(web): gate content search on filesystem access --- .../ProjectContentSearchDialog.test.tsx | 123 +++++++++++++++ .../search/ProjectContentSearchDialog.tsx | 28 +++- apps/web/src/state/queries.filesystem.test.ts | 145 +++++++++++++++++- apps/web/src/state/queries.ts | 31 +++- 4 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/search/ProjectContentSearchDialog.test.tsx diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.test.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.test.tsx new file mode 100644 index 000000000000..6362044babbf --- /dev/null +++ b/apps/web/src/components/search/ProjectContentSearchDialog.test.tsx @@ -0,0 +1,123 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { AuthFilesystemReadScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canReadFiles: true, + isCheckingAccess: false, + error: null as string | null, + readScope: vi.fn(), + openFile: vi.fn(), +})); + +const target = { + environmentId: EnvironmentId.make("content-search-secondary"), + cwd: "/project", + projectName: "Project", + threadRef: scopeThreadRef( + EnvironmentId.make("content-search-secondary"), + ThreadId.make("content-search-thread"), + ), +}; + +vi.mock("~/hooks/useActiveProjectTarget", () => ({ useActiveProjectTarget: () => target })); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, +})); +vi.mock("~/state/session", () => ({ readEnvironmentScope: state.readScope })); +vi.mock("~/state/queries", () => ({ + useProjectContentSearch: ({ query }: { query: string }) => ({ + canReadFiles: state.canReadFiles, + isCheckingAccess: state.isCheckingAccess, + error: state.error, + isPending: state.isCheckingAccess, + hasQuery: query.length > 0, + truncated: false, + invalidRegex: false, + matches: + state.canReadFiles && query.length > 0 + ? [{ path: "src/index.ts", lineNumber: 3, lineContent: "match", matchRanges: [] }] + : [], + }), +})); +vi.mock("../CommandPaletteContent", () => ({ CommandPaletteContent: "section" })); +vi.mock("../chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("./HighlightedSearchLine", () => ({ HighlightedSearchLine: () => null })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); +vi.mock("../ui/toggle", () => ({ Toggle: "button" })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "div", + TooltipPopup: "span", + TooltipTrigger: ({ render }: { render: ReactNode }) => render, +})); + +import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog"; + +let renderer: ReactTestRenderer | undefined; +const onOpenChange = vi.fn(); + +beforeEach(() => { + state.canReadFiles = true; + state.isCheckingAccess = false; + state.error = null; + state.readScope.mockReset().mockReturnValue(true); + state.openFile.mockClear(); + onOpenChange.mockClear(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { querySelector: () => null }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +async function openSearch() { + await act(() => { + renderer = create(); + }); + return renderer!.root; +} + +it("enables search after access resolves and opens a result in its own environment", async () => { + state.canReadFiles = false; + state.isCheckingAccess = true; + const root = await openSearch(); + expect(root.findByType("section").props.inputProps.disabled).toBe(true); + expect( + root.findAllByType("div").some((node) => node.children.includes("Checking file access…")), + ).toBe(true); + + state.canReadFiles = true; + state.isCheckingAccess = false; + await act(() => renderer!.update()); + expect(root.findByType("section").props.inputProps.disabled).toBe(false); + await act(() => root.findByType("section").props.onValueChange("match")); + await act(() => root.findByProps({ "data-content-search-result": 0 }).props.onClick()); + + expect(state.readScope).toHaveBeenCalledWith(target.environmentId, AuthFilesystemReadScope); + expect(state.openFile).toHaveBeenCalledWith(target.threadRef, "src/index.ts", 3); + expect(onOpenChange).toHaveBeenCalledWith(false); +}); + +it.each(["pointer", "keyboard"])( + "rechecks access before a retained %s action opens a result", + async (action) => { + const root = await openSearch(); + await act(() => root.findByType("section").props.onValueChange("match")); + const openResult = root.findByProps({ "data-content-search-result": 0 }).props.onClick; + const onKeyDown = root.findByType("section").props.inputProps.onKeyDown; + + state.readScope.mockReturnValue(false); + await act(() => { + if (action === "pointer") openResult(); + else onKeyDown({ key: "Enter", preventDefault() {} }); + }); + + expect(state.openFile).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }, +); diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..780a3dd9c263 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,4 +1,4 @@ -import type { ProjectContentMatch } from "@t3tools/contracts"; +import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts"; import { LoaderCircle } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; @@ -7,6 +7,7 @@ import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { useRightPanelStore } from "~/rightPanelStore"; import { useProjectContentSearch } from "~/state/queries"; +import { readEnvironmentScope } from "~/state/session"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; @@ -55,6 +56,7 @@ function groupMatches(matches: ReadonlyArray): MatchGroup[] function SearchOptionButton(props: { readonly active: boolean; + readonly disabled: boolean; readonly label: string; readonly onClick: () => void; readonly children: ReactNode; @@ -65,6 +67,7 @@ function SearchOptionButton(props: { render={ matches.slice(0, visibleCount), [matches, visibleCount]); const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); @@ -151,13 +154,16 @@ function OpenContentSearchDialog(props: { }, []); const openMatch = (match: ProjectContentMatch) => { - if (!canOpenMatches) return; + if (!canOpenMatches || !readEnvironmentScope(target.environmentId, AuthFilesystemReadScope)) { + return; + } props.onOpenChange(false); useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); }; const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); const showSearchStatus = - search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; + search.canReadFiles && + (search.hasQuery || search.isPending || search.error !== null || search.invalidRegex); return ( setCaseSensitive((current) => !current)} > @@ -175,6 +182,7 @@ function OpenContentSearchDialog(props: { setWholeWord((current) => !current)} > @@ -182,6 +190,7 @@ function OpenContentSearchDialog(props: { setUseRegex((current) => !current)} > @@ -191,6 +200,7 @@ function OpenContentSearchDialog(props: { } inputProps={{ className: "pe-30", + disabled: !search.canReadFiles, placeholder: `Search in ${target.projectName}`, onKeyDown: (event) => { if (event.key === "ArrowDown" && matches.length > 0) { @@ -239,9 +249,13 @@ function OpenContentSearchDialog(props: { {matches.length === 0 ? (
- {search.hasQuery && !search.isPending && !search.error - ? "No results found." - : "Type to search across your project."} + {search.isCheckingAccess + ? "Checking file access…" + : !search.canReadFiles + ? search.error + : search.hasQuery && !search.isPending && !search.error + ? "No results found." + : "Type to search across your project."}
) : ( diff --git a/apps/web/src/state/queries.filesystem.test.ts b/apps/web/src/state/queries.filesystem.test.ts index 3497031082af..7cff8b1e5ada 100644 --- a/apps/web/src/state/queries.filesystem.test.ts +++ b/apps/web/src/state/queries.filesystem.test.ts @@ -4,9 +4,17 @@ import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ session: null as Pick | null, sessionError: null as string | null, + sessionWaiting: false, phase: "connected" as "connected" | "offline", sessionAtom: {}, searchAtom: {}, + contentAtom: {}, + contentRequests: vi.fn(), + contentError: null as string | null, + contentData: { + matches: [{ path: "src/index.ts", lineNumber: 3, lineContent: "a match", matchRanges: [] }], + truncated: false, + }, })); vi.mock("react", async (importOriginal) => ({ @@ -27,6 +35,10 @@ vi.mock("./presentation", () => ({ })); vi.mock("./projects", () => ({ projectEnvironment: { searchEntries: () => state.searchAtom }, + projectContentSearch: (target: unknown) => { + state.contentRequests(target); + return state.contentAtom; + }, })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); @@ -39,14 +51,23 @@ vi.mock("./query", () => ({ ? state.session : atom === state.searchAtom ? { entries: [{ path: "src/index.ts", kind: "file" }] } + : atom === state.contentAtom + ? state.contentData + : null, + error: + atom === state.sessionAtom + ? state.sessionError + : atom === state.contentAtom + ? state.contentError : null, - error: atom === state.sessionAtom ? state.sessionError : null, - isPending: atom === state.sessionAtom && state.session === null && state.sessionError === null, + isPending: + atom === state.sessionAtom && + (state.sessionWaiting || (state.session === null && state.sessionError === null)), refresh: vi.fn(), }), })); -import { useProjectPathSearch } from "./queries"; +import { useProjectContentSearch, useProjectPathSearch } from "./queries"; const useComposerPathSearch = (input: Parameters[0]) => useProjectPathSearch(input, 20); @@ -60,7 +81,10 @@ const target = { beforeEach(() => { state.session = null; state.sessionError = null; + state.sessionWaiting = false; state.phase = "connected"; + state.contentRequests.mockClear(); + state.contentError = null; }); it("keeps a file search pending until its grant loads, then shows matches", () => { @@ -100,3 +124,118 @@ it("leaves an inactive search idle while its grant loads", () => { isPending: false, }); }); + +const contentTarget = { + ...target, + query: " a match ", + caseSensitive: true, + wholeWord: true, + useRegex: false, +}; + +it("waits for content-search access before issuing a request, including an empty search", () => { + for (const query of ["", contentTarget.query]) { + const result = useProjectContentSearch({ ...contentTarget, query }); + expect(state.contentRequests).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + canReadFiles: false, + isCheckingAccess: true, + matches: [], + error: null, + isPending: true, + }); + } + expect(state.contentRequests).not.toHaveBeenCalled(); +}); + +it("shows content-search denial before typing and never issues an unauthorized request", () => { + state.session = { authenticated: true, scopes: [] }; + for (const query of ["", contentTarget.query]) { + const result = useProjectContentSearch({ ...contentTarget, query }); + expect(state.contentRequests).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + canReadFiles: false, + isCheckingAccess: false, + matches: [], + error: "This connection cannot search host files.", + isPending: false, + }); + } + expect(state.contentRequests).not.toHaveBeenCalled(); +}); + +it("preserves content query whitespace and options when access is granted", () => { + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + expect(useProjectContentSearch(contentTarget)).toMatchObject({ + matches: state.contentData.matches, + error: null, + isPending: false, + }); + expect(state.contentRequests).toHaveBeenCalledWith({ + environmentId: contentTarget.environmentId, + input: { + cwd: contentTarget.cwd, + query: contentTarget.query, + limit: 500, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }, + }); +}); + +it("keeps confirmed content access during revalidation and drops results when it is revoked", () => { + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + state.sessionWaiting = true; + expect(useProjectContentSearch(contentTarget)).toMatchObject({ + matches: state.contentData.matches, + isPending: false, + }); + + state.contentRequests.mockClear(); + state.sessionWaiting = false; + state.session = { authenticated: true, scopes: [] }; + const result = useProjectContentSearch(contentTarget); + expect(state.contentRequests).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + canReadFiles: false, + matches: [], + error: "This connection cannot search host files.", + isPending: false, + }); + expect(state.contentRequests).not.toHaveBeenCalled(); +}); + +it("fails closed after a session check fails, even with a cached grant and matches", () => { + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + state.sessionError = "The session has expired."; + const result = useProjectContentSearch(contentTarget); + expect(state.contentRequests).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + canReadFiles: false, + matches: [], + error: state.sessionError, + isPending: false, + }); + expect(state.contentRequests).not.toHaveBeenCalled(); +}); + +it("keeps inactive content search idle and reports a disconnected target without querying", () => { + expect( + useProjectContentSearch({ ...contentTarget, environmentId: null, cwd: null }), + ).toMatchObject({ + matches: [], + error: null, + isPending: false, + }); + state.phase = "offline"; + const result = useProjectContentSearch(contentTarget); + expect(state.contentRequests).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + canReadFiles: false, + matches: [], + error: "This environment is not connected.", + isPending: false, + }); + expect(state.contentRequests).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index e00a81ad6bef..edbdce8aa0ae 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -337,13 +337,29 @@ interface ProjectContentSearchTarget { } export function useProjectContentSearch(target: ProjectContentSearchTarget) { + const hasTarget = target.environmentId !== null && target.cwd !== null; + const fileAccessSession = useEnvironmentQuery( + target.environmentId === null + ? null + : environmentSession.sessionStateAtom(target.environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(target.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const canReadFiles = hasTarget && fileAccess.canReadFiles; + const isCheckingAccess = hasTarget && fileAccess.isPending; // Whitespace is significant in content queries; trimming is only used to // decide whether the input is blank. const query = target.query; const hasQuery = query.trim().length > 0; const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( - target.environmentId !== null && + canReadFiles && + target.environmentId !== null && target.cwd !== null && hasQuery && debouncedQuery.trim().length > 0 @@ -362,9 +378,18 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { ); return { + canReadFiles, + isCheckingAccess, matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, - error: result.error, - isPending: hasQuery && (query !== debouncedQuery || result.isPending), + error: + !hasTarget || isCheckingAccess + ? null + : canReadFiles + ? result.error + : (fileAccess.error ?? "This connection cannot search host files."), + isPending: + isCheckingAccess || + (canReadFiles && hasQuery && (query !== debouncedQuery || result.isPending)), hasQuery, truncated: result.data?.truncated ?? false, invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, From d7bafb29c31fe1fd4e69f9c31286ca0a26c7870d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:21:21 -0700 Subject: [PATCH 24/29] fix(clients): gate host media actions by file permission --- apps/mobile/src/lib/mediaActions.test.ts | 209 +++++++++++++++ apps/mobile/src/lib/mediaActions.ts | 47 +++- .../ChatMarkdown.workspace-images.test.tsx | 9 + .../components/media/MediaActions.test.tsx | 246 ++++++++++++++++++ .../web/src/components/media/MediaActions.tsx | 59 ++++- 5 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/lib/mediaActions.test.ts create mode 100644 apps/web/src/components/media/MediaActions.test.tsx diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts new file mode 100644 index 000000000000..536bd16d363b --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -0,0 +1,209 @@ +import { + AuthFilesystemReadScope, + EnvironmentId, + ThreadId, + type AuthSessionState, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + sessions: new Map>(), + refresh: vi.fn(), + download: vi.fn(), + shareLocal: vi.fn(), + shareDraft: vi.fn(), + navigate: vi.fn(), + copy: vi.fn(), +})); + +vi.mock("react", () => ({ + useEffect: () => {}, + useRef:
(current: A) => ({ current }), + useState: (initial: A) => [initial, () => {}], +})); +vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ navigate: state.navigate }), +})); +vi.mock("@t3tools/mobile-markdown-text/links", () => ({ + normalizeNativeMarkdownUrl: (uri: string) => uri, +})); +vi.mock("../state/assets", () => ({ + useRefreshAssetUrl: (environmentId: string, resource: unknown) => () => + state.refresh(environmentId, resource), +})); +vi.mock("../state/session", () => ({ + environmentSession: { sessionStateAtom: (environmentId: string) => environmentId }, +})); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: (environmentId: string) => ({ + data: state.sessions.get(environmentId) ?? null, + error: null, + }), +})); +vi.mock("../state/atom-registry", () => ({ + appAtomRegistry: { + get: (environmentId: string) => { + const session = state.sessions.get(environmentId); + return session === undefined ? AsyncResult.initial() : AsyncResult.success(session); + }, + }, +})); +vi.mock("./attachmentDownload", () => ({ + downloadAndShareAttachment: state.download, + shareLocalAttachment: state.shareLocal, +})); +vi.mock("./copyTextWithHaptic", () => ({ copyTextWithHaptic: state.copy })); +vi.mock("./localAttachmentPreview", () => ({ + loadLocalAttachmentPreview: async () => ({ share: state.shareDraft, dispose: vi.fn() }), +})); + +import { useMediaActions, type MediaActionsSource } from "./mediaActions"; + +const environmentId = EnvironmentId.make("media-environment"); +const otherEnvironmentId = EnvironmentId.make("other-environment"); +const threadId = ThreadId.make("media-thread"); +const granted: Pick = { + authenticated: true, + scopes: [AuthFilesystemReadScope], +}; +const denied: Pick = { + authenticated: true, + scopes: [], +}; + +function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionsSource { + return { + environmentId, + threadId, + resource: { _tag, threadId, path: "/repo/image.png" }, + reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" }, + name: "image.png", + mimeType: "image/png", + }; +} + +beforeEach(() => { + state.sessions.clear(); + state.sessions.set(environmentId, denied); + state.refresh.mockReset().mockResolvedValue("https://host.test/image.png"); + state.download.mockReset().mockResolvedValue(undefined); + state.shareLocal.mockReset().mockResolvedValue(undefined); + state.shareDraft.mockReset().mockResolvedValue(undefined); + state.navigate.mockReset(); + state.copy.mockReset(); +}); + +it.each(["workspace-file", "media-file"] as const)( + "blocks denied %s sharing and file opening while preserving path copying", + async (_tag) => { + const media = useMediaActions(hostSource(_tag)); + await media.share(); + media.actions.find(({ id }) => id === "open-file")!.run(); + media.actions.find(({ id }) => id === "copy-full-path")!.run(); + + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.copy).toHaveBeenCalledWith("/repo/image.png"); + expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(true); + expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(true); + }, +); + +it("rechecks a retained menu action after revocation", async () => { + state.sessions.set(environmentId, granted); + const media = useMediaActions(hostSource()); + state.sessions.set(environmentId, denied); + + await media.share(); + media.actions.find(({ id }) => id === "open-file")!.run(); + + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); +}); + +it("reenables sharing after gaining access while preserving a retained callback", async () => { + const media = useMediaActions(hostSource()); + state.sessions.set(environmentId, granted); + + await media.share(); + + expect(state.download).toHaveBeenCalledOnce(); + expect(useMediaActions(hostSource()).actions.find(({ id }) => id === "save")?.disabled).toBe( + false, + ); +}); + +it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => { + state.sessions.set(environmentId, allowed ? granted : denied); + state.sessions.set(otherEnvironmentId, allowed ? denied : granted); + + await useMediaActions(hostSource()).share(); + + expect(state.refresh).toHaveBeenCalledTimes(allowed ? 1 : 0); + expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0); +}); + +it("stops before downloading if access is revoked while the URL is refreshed", async () => { + state.sessions.set(environmentId, granted); + state.refresh.mockImplementation(async () => { + state.sessions.set(environmentId, denied); + return "https://host.test/image.png"; + }); + + await useMediaActions(hostSource()).share(); + + expect(state.refresh).toHaveBeenCalledOnce(); + expect(state.download).not.toHaveBeenCalled(); +}); + +it("lets an unresolved grant be authorized by an explicit server request", async () => { + state.sessions.delete(environmentId); + + await useMediaActions(hostSource()).share(); + + expect(state.refresh).toHaveBeenCalledOnce(); + expect(state.download).toHaveBeenCalledOnce(); +}); + +it("shares uploaded attachments without host filesystem access", async () => { + await useMediaActions({ + environmentId, + resource: { _tag: "attachment", attachmentId: "upload" }, + name: "image.png", + mimeType: "image/png", + }).share(); + + expect(state.refresh).toHaveBeenCalledOnce(); + expect(state.download).toHaveBeenCalledOnce(); +}); + +it.each(["https://cdn.test/image.png", "file:///device/image.png"])( + "shares direct media without a host grant: %s", + async (uri) => { + await useMediaActions({ uri, name: "image.png", mimeType: "image/png" }).share(); + + expect(state.refresh).not.toHaveBeenCalled(); + expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce(); + }, +); + +it("shares device draft attachments without a host grant", async () => { + await useMediaActions({ + attachment: { + id: "draft", + type: "file", + fileUri: "file:///device/image.png", + name: "image.png", + mimeType: "image/png", + sizeBytes: 1, + }, + name: "image.png", + mimeType: "image/png", + }).share(); + + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.shareDraft).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index c37ed76c2bf5..cff3abfb3520 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -1,12 +1,23 @@ import { useNavigation } from "@react-navigation/native"; import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import type { MediaReference } from "@t3tools/client-runtime/media-reference"; -import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + type AssetResource, + type AuthSessionState, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; import { useRefreshAssetUrl } from "../state/assets"; +import { appAtomRegistry } from "../state/atom-registry"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentSession } from "../state/session"; import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; import type { DraftComposerFileAttachment } from "./composerImages"; import { copyTextWithHaptic } from "./copyTextWithHaptic"; @@ -29,8 +40,32 @@ export type MediaActionsSource = { } ); +/** An explicit action may ask the server while its grant is still unresolved. */ +function allowsHostMedia(session: Pick | null) { + return ( + session === null || + (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) + ); +} + +function canReadHostMedia(environmentId: EnvironmentId | null): boolean { + if (environmentId === null) return true; + const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); + return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); +} + export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { const navigation = useNavigation(); + const hostEnvironmentId = + source && + "resource" in source && + (source.resource._tag === "workspace-file" || source.resource._tag === "media-file") + ? source.environmentId + : null; + const fileSession = useEnvironmentQuery( + hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), + ); + const canReadMedia = fileSession.error === null && allowsHostMedia(fileSession.data); const refresh = useRefreshAssetUrl( source && "environmentId" in source ? source.environmentId : null, source && "resource" in source ? source.resource : null, @@ -40,11 +75,11 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi useEffect(() => () => controller.current?.abort(), []); const share = () => { - if (!source || controller.current) return; + if (!source || controller.current || !canReadHostMedia(hostEnvironmentId)) return; const request = new AbortController(); controller.current = request; setSharing(true); - void (async () => { + return (async () => { if ("attachment" in source) { const preview = await loadLocalAttachmentPreview(source.attachment, request.signal); if (!preview) return; @@ -56,7 +91,7 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi return; } const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); - if (request.signal.aborted) return; + if (request.signal.aborted || !canReadHostMedia(hostEnvironmentId)) return; if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); const input = { attachment: { name: source.name, mimeType: source.mimeType }, @@ -120,7 +155,9 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi { id: "open-file" as const, title: "Open in file viewer", + disabled: !canReadMedia, run: () => { + if (!canReadHostMedia(hostEnvironmentId)) return; onOpenFile?.(); navigation.navigate("ThreadFile", { environmentId: String(source.environmentId), @@ -135,7 +172,7 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi id: "save" as const, title: sharing ? "Opening share sheet…" : "Save or share", run: share, - disabled: sharing, + disabled: sharing || !canReadMedia, }, ] : []; diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 207f7e1f8ec3..7bca07477c6e 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -20,6 +20,15 @@ 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/query", async () => { + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + return { + useEnvironmentQuery: () => ({ + data: { authenticated: true, scopes: AuthStandardClientScopes }, + error: null, + }), + }; +}); vi.mock("../state/session", async (importOriginal) => { const actual = await importOriginal(); const { AuthStandardClientScopes } = await import("@t3tools/contracts"); diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx new file mode 100644 index 000000000000..a39f4d47a2a5 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -0,0 +1,246 @@ +import { + AuthFilesystemReadScope, + EnvironmentId, + ThreadId, + type AuthSessionState, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { createElement, isValidElement, type ReactNode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + sessions: new Map>(), + mint: vi.fn(), + download: vi.fn(), + png: vi.fn(), + showMenu: vi.fn(), + openFile: vi.fn(), + clipboard: vi.fn(), + menuFinished: null as (() => void) | null, +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: A) => callback, + useRef: (current: A) => ({ current }), + useState: (initial: A) => [initial, () => {}], +})); +vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() })); +vi.mock("../../localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: state.showMenu } }), +})); +vi.mock("../../state/assets", () => ({ assetEnvironment: { createUrl: {} } })); +vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); +vi.mock("../../state/session", () => ({ + environmentSession: { sessionStateAtom: (environmentId: string) => environmentId }, + readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }), +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (environmentId: string) => ({ + data: state.sessions.get(environmentId) ?? null, + error: null, + }), +})); +vi.mock("../../rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: (environmentId: string) => { + const session = state.sessions.get(environmentId); + return session === undefined ? AsyncResult.initial() : AsyncResult.success(session); + }, + }, +})); +vi.mock("./mediaContent", () => ({ downloadMedia: state.download, readMediaPng: state.png })); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "Tooltip", + TooltipTrigger: "TooltipTrigger", + TooltipPopup: "TooltipPopup", +})); +vi.mock("../ui/toast", () => ({ + stackedThreadToast: (toast: A) => toast, + toastManager: { + add: (toast: { type: string }) => { + if (toast.type !== "loading") state.menuFinished?.(); + return "toast"; + }, + update: () => state.menuFinished?.(), + }, +})); + +import { MediaActions, useMediaActions, type MediaActionSource } from "./MediaActions"; + +const environmentId = EnvironmentId.make("media-environment"); +const otherEnvironmentId = EnvironmentId.make("other-environment"); +const threadId = ThreadId.make("media-thread"); +const granted: Pick = { + authenticated: true, + scopes: [AuthFilesystemReadScope], +}; +const denied: Pick = { + authenticated: true, + scopes: [], +}; + +function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionSource { + return { + kind: "image", + name: "image.png", + src: null, + asset: { environmentId, resource: { _tag, threadId, path: "/repo/image.png" } }, + reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" }, + onOpenFile: state.openFile, + }; +} + +function openMenu(source: MediaActionSource) { + const find = (node: ReactNode): ((event: unknown) => void) | undefined => { + if (Array.isArray(node)) return node.map(find).find((handler) => handler !== undefined); + if (!isValidElement<{ children?: ReactNode; onContextMenu?: (event: unknown) => void }>(node)) + return undefined; + return node.props.onContextMenu ?? find(node.props.children); + }; + const handler = find(MediaActions({ source, children: createElement("img") })); + if (!handler) throw new Error("Media menu handler missing"); + handler({ + defaultPrevented: false, + preventDefault() {}, + stopPropagation() {}, + currentTarget: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + clientX: 1, + clientY: 1, + }); +} + +beforeEach(() => { + state.sessions.clear(); + state.sessions.set(environmentId, denied); + state.mint + .mockReset() + .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); + state.download.mockReset().mockResolvedValue(undefined); + state.png.mockReset().mockResolvedValue(new Blob(["png"], { type: "image/png" })); + state.showMenu.mockReset().mockResolvedValue(null); + state.openFile.mockReset(); + state.menuFinished = null; + class TestClipboardItem { + constructor(readonly items: Record>) {} + } + state.clipboard.mockReset().mockImplementation(async (items: TestClipboardItem[]) => { + await Promise.all(items.flatMap((item) => Object.values(item.items))); + }); + vi.stubGlobal("ClipboardItem", TestClipboardItem); + vi.stubGlobal("navigator", { clipboard: { write: state.clipboard } }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +it.each(["workspace-file", "media-file"] as const)( + "disables denied %s byte actions and prevents imperative requests", + async (_tag) => { + const source = hostSource(_tag); + openMenu(source); + expect(state.showMenu).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "save", disabled: true }), + expect.objectContaining({ id: "copy-image", disabled: true }), + expect.objectContaining({ id: "open-file", disabled: true }), + ]), + { x: 1, y: 1 }, + ); + await expect(useMediaActions(source).save()).rejects.toThrow("cannot read host files"); + await expect(useMediaActions(source).copyImage()).rejects.toThrow("cannot read host files"); + expect(state.mint).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + expect(state.clipboard).not.toHaveBeenCalled(); + }, +); + +it.each(["save", "copy-image", "open-file"])( + "rechecks access when %s is selected from an already open native menu", + async (action) => { + state.sessions.set(environmentId, granted); + const choice = Promise.withResolvers(); + const completed = Promise.withResolvers(); + state.showMenu.mockReturnValue(choice.promise); + state.menuFinished = () => completed.resolve(); + state.openFile.mockImplementation(() => completed.resolve()); + openMenu(hostSource()); + + state.sessions.set(environmentId, denied); + choice.resolve(action); + await completed.promise; + + expect(state.mint).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + expect(state.clipboard).not.toHaveBeenCalled(); + expect(state.openFile).not.toHaveBeenCalled(); + }, +); + +it("reenables the menu and a retained action when file access is gained", async () => { + const actions = useMediaActions(hostSource()); + state.sessions.set(environmentId, granted); + openMenu(hostSource()); + + await actions.save(); + + expect(state.download).toHaveBeenCalledOnce(); + expect(state.showMenu).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: "save", disabled: false })]), + expect.anything(), + ); +}); + +it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => { + state.sessions.set(environmentId, allowed ? granted : denied); + state.sessions.set(otherEnvironmentId, allowed ? denied : granted); + + await useMediaActions(hostSource()) + .save() + .catch(() => {}); + + expect(state.mint).toHaveBeenCalledTimes(allowed ? 1 : 0); + expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0); +}); + +it("stops before downloading if access is revoked while minting the URL", async () => { + state.sessions.set(environmentId, granted); + state.mint.mockImplementation(async () => { + state.sessions.set(environmentId, denied); + return AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); + }); + + await expect(useMediaActions(hostSource()).save()).rejects.toThrow("cannot read host files"); + + expect(state.download).not.toHaveBeenCalled(); +}); + +it("lets the server authorize an explicit action before the grant resolves", async () => { + state.sessions.delete(environmentId); + + await useMediaActions(hostSource()).save(); + + expect(state.mint).toHaveBeenCalledOnce(); + expect(state.download).toHaveBeenCalledOnce(); +}); + +it("saves uploaded attachments without filesystem access", async () => { + await useMediaActions({ + kind: "image", + name: "image.png", + src: null, + asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, + }).save(); + + expect(state.mint).toHaveBeenCalledOnce(); + expect(state.download).toHaveBeenCalledOnce(); +}); + +it.each(["https://cdn.test/image.png", "blob:local-image"])( + "saves direct media without a host grant: %s", + async (src) => { + await useMediaActions({ kind: "image", name: "image.png", src }).save(); + + expect(state.mint).not.toHaveBeenCalled(); + expect(state.download).toHaveBeenCalledWith(src, "image.png"); + }, +); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index cf79c81b9f60..c104ae5afa8a 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -5,13 +5,23 @@ import { } from "@t3tools/client-runtime/media-reference"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + type AssetResource, + type AuthSessionState, + type ContextMenuItem, + type EnvironmentId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useRef, useState, type ReactElement } from "react"; import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; import { readLocalApi } from "../../localApi"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { assetEnvironment } from "../../state/assets"; -import { readPreparedConnection } from "../../state/session"; +import { useEnvironmentQuery } from "../../state/query"; +import { environmentSession, readPreparedConnection } from "../../state/session"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -32,13 +42,42 @@ function mediaFileName(source: MediaActionSource): string { ); } +/** An explicit action may ask the server while its grant is still unresolved. */ +function allowsHostMedia(session: Pick | null) { + return ( + session === null || + (session.authenticated && session.scopes?.includes(AuthFilesystemReadScope) === true) + ); +} + +function canReadHostMedia(environmentId: EnvironmentId | null): boolean { + if (environmentId === null) return true; + const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); + return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); +} + /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ export function useMediaActions(source: MediaActionSource) { + const hostEnvironmentId = + source.asset && + (source.asset.resource._tag === "workspace-file" || source.asset.resource._tag === "media-file") + ? source.asset.environmentId + : null; + const fileSession = useEnvironmentQuery( + hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), + ); + const canReadMedia = fileSession.error === null && allowsHostMedia(fileSession.data); + const assertCanReadMedia = useCallback(() => { + if (!canReadHostMedia(hostEnvironmentId)) { + throw new Error("This connection cannot read host files."); + } + }, [hostEnvironmentId]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); const actionUrl = useCallback(async () => { + assertCanReadMedia(); if (!source.asset) { if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); return source.src; @@ -48,14 +87,16 @@ export function useMediaActions(source: MediaActionSource) { if (!connection) throw new Error("Reconnect to this environment and try again."); const result = await createAssetUrl({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); + assertCanReadMedia(); const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); if (!url) throw new Error("The environment returned an invalid media URL."); return url; - }, [source, createAssetUrl]); + }, [source, createAssetUrl, assertCanReadMedia]); const save = useCallback(async () => { await downloadMedia(await actionUrl(), mediaFileName(source)); }, [actionUrl, source]); const copyImage = useCallback(async () => { + assertCanReadMedia(); if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { throw new Error( "Image copying is unavailable. Use a secure browser connection or save the image.", @@ -65,8 +106,8 @@ export function useMediaActions(source: MediaActionSource) { await navigator.clipboard.write([ new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), ]); - }, [actionUrl]); - return { save, copyImage }; + }, [actionUrl, assertCanReadMedia]); + return { save, copyImage, canReadMedia, assertCanReadMedia }; } /** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ @@ -77,7 +118,7 @@ export function MediaActions({ source: MediaActionSource; children: ReactElement; }) { - const { save, copyImage } = useMediaActions(source); + const { save, copyImage, canReadMedia, assertCanReadMedia } = useMediaActions(source); const [tooltipOpen, setTooltipOpen] = useState(false); const menuOpen = useRef(false); const reference = source.reference; @@ -92,7 +133,7 @@ export function MediaActions({ let progressToast: ReturnType | undefined; try { const noun = source.kind === "image" ? "image" : "video"; - const unavailable = source.src === null && source.asset === undefined; + const unavailable = !canReadMedia || (source.src === null && source.asset === undefined); const canCopyImage = typeof navigator !== "undefined" && Boolean(navigator.clipboard?.write) && @@ -105,7 +146,8 @@ export function MediaActions({ } else if (reference?.kind === "url") { items.push({ id: "copy-url", label: "Copy URL" }); } - if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + if (source.onOpenFile) + items.push({ id: "open-file", label: "Open in file viewer", disabled: !canReadMedia }); items.push({ id: "save", label: `Save ${noun}`, disabled: unavailable }); if (source.kind === "image") { items.push({ @@ -133,6 +175,7 @@ export function MediaActions({ title: action === "copy-url" ? "URL copied" : "Path copied", }); } else if (action === "open-file") { + assertCanReadMedia(); source.onOpenFile?.(); } else if (action === "save" || action === "copy-image") { progressToast = toastManager.add({ From e7f5f95dc9bb77f4245999b0bd90e4ecc94ed2c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:30:13 -0700 Subject: [PATCH 25/29] fix(clients): wait for media grants before enabling actions --- apps/mobile/src/lib/mediaActions.test.ts | 56 +++++++++++++++++++ apps/mobile/src/lib/mediaActions.ts | 4 +- .../components/media/MediaActions.test.tsx | 53 ++++++++++++++++++ .../web/src/components/media/MediaActions.tsx | 4 +- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts index 536bd16d363b..b9a69237edd3 100644 --- a/apps/mobile/src/lib/mediaActions.test.ts +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -95,6 +95,62 @@ beforeEach(() => { state.copy.mockReset(); }); +it.each(["workspace-file", "media-file"] as const)( + "waits for the %s grant before enabling host menu actions", + (_tag) => { + for (const [session, disabled] of [ + [null, true], + [granted, false], + [denied, true], + ] as const) { + if (session === null) state.sessions.delete(environmentId); + else state.sessions.set(environmentId, session); + const media = useMediaActions(hostSource(_tag)); + + expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(disabled); + expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(disabled); + const copyPath = media.actions.find(({ id }) => id === "copy-full-path")!; + expect(copyPath.disabled).not.toBe(true); + copyPath.run(); + } + expect(state.copy).toHaveBeenCalledTimes(3); + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + }, +); + +it("keeps nonhost menu actions available with pending or denied host grants", () => { + const sources: MediaActionsSource[] = [ + { uri: "https://cdn.test/image.png", name: "image.png", mimeType: "image/png" }, + { uri: "file:///device/image.png", name: "image.png", mimeType: "image/png" }, + { + environmentId, + resource: { _tag: "attachment", attachmentId: "upload" }, + name: "image.png", + mimeType: "image/png", + }, + { + attachment: { + id: "draft", + type: "file", + fileUri: "file:///device/image.png", + name: "image.png", + mimeType: "image/png", + sizeBytes: 1, + }, + name: "image.png", + mimeType: "image/png", + }, + ]; + for (const session of [null, denied]) { + if (session === null) state.sessions.delete(environmentId); + else state.sessions.set(environmentId, session); + for (const source of sources) { + expect(useMediaActions(source).actions.find(({ id }) => id === "save")?.disabled).toBe(false); + } + } +}); + it.each(["workspace-file", "media-file"] as const)( "blocks denied %s sharing and file opening while preserving path copying", async (_tag) => { diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index cff3abfb3520..c3c6b425139a 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -65,7 +65,9 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi const fileSession = useEnvironmentQuery( hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), ); - const canReadMedia = fileSession.error === null && allowsHostMedia(fileSession.data); + const canReadMedia = + hostEnvironmentId === null || + (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data)); const refresh = useRefreshAssetUrl( source && "environmentId" in source ? source.environmentId : null, source && "resource" in source ? source.resource : null, diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx index a39f4d47a2a5..77d8f14ff0ca 100644 --- a/apps/web/src/components/media/MediaActions.test.tsx +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -133,6 +133,59 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); +it.each(["workspace-file", "media-file"] as const)( + "waits for the %s grant before enabling host menu actions", + (_tag) => { + for (const [session, disabled] of [ + [null, true], + [granted, false], + [denied, true], + ] as const) { + if (session === null) state.sessions.delete(environmentId); + else state.sessions.set(environmentId, session); + openMenu(hostSource(_tag)); + + const items = state.showMenu.mock.lastCall![0]; + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "save", disabled }), + expect.objectContaining({ id: "copy-image", disabled }), + expect.objectContaining({ id: "open-file", disabled }), + ]), + ); + expect(items).toContainEqual({ id: "copy-full-path", label: "Copy full path" }); + } + expect(state.mint).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + }, +); + +it("keeps nonhost menu actions available with pending or denied host grants", () => { + const sources: MediaActionSource[] = [ + { kind: "image", name: "image.png", src: "https://cdn.test/image.png" }, + { kind: "image", name: "image.png", src: "blob:local-image" }, + { + kind: "image", + name: "image.png", + src: null, + asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, + }, + ]; + for (const session of [null, denied]) { + if (session === null) state.sessions.delete(environmentId); + else state.sessions.set(environmentId, session); + for (const source of sources) { + openMenu(source); + expect(state.showMenu.mock.lastCall![0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "save", disabled: false }), + expect.objectContaining({ id: "copy-image", disabled: false }), + ]), + ); + } + } +}); + it.each(["workspace-file", "media-file"] as const)( "disables denied %s byte actions and prevents imperative requests", async (_tag) => { diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index c104ae5afa8a..9e426497f2ea 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -66,7 +66,9 @@ export function useMediaActions(source: MediaActionSource) { const fileSession = useEnvironmentQuery( hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId), ); - const canReadMedia = fileSession.error === null && allowsHostMedia(fileSession.data); + const canReadMedia = + hostEnvironmentId === null || + (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data)); const assertCanReadMedia = useCallback(() => { if (!canReadHostMedia(hostEnvironmentId)) { throw new Error("This connection cannot read host files."); From 0023bd32bf9bd53861d8eabe17888bde157f493d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:53:06 -0700 Subject: [PATCH 26/29] test(web): use compatible media action receipts --- apps/web/src/components/media/MediaActions.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx index 77d8f14ff0ca..18d88b29b868 100644 --- a/apps/web/src/components/media/MediaActions.test.tsx +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -8,6 +8,14 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { createElement, isValidElement, type ReactNode } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + const state = vi.hoisted(() => ({ sessions: new Map>(), mint: vi.fn(), @@ -211,8 +219,8 @@ it.each(["save", "copy-image", "open-file"])( "rechecks access when %s is selected from an already open native menu", async (action) => { state.sessions.set(environmentId, granted); - const choice = Promise.withResolvers(); - const completed = Promise.withResolvers(); + const choice = deferred(); + const completed = deferred(); state.showMenu.mockReturnValue(choice.promise); state.menuFinished = () => completed.resolve(); state.openFile.mockImplementation(() => completed.resolve()); From 271a7355c2b09d00befb3b9750f5331d5997b47b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:30 -0700 Subject: [PATCH 27/29] test(web): clean up highlight fixture frames --- .../files/fileEditorHighlight.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts index 4de146835246..48c6a5e38447 100644 --- a/apps/web/src/components/files/fileEditorHighlight.test.ts +++ b/apps/web/src/components/files/fileEditorHighlight.test.ts @@ -56,6 +56,7 @@ interface HeldResponse { data: WorkerResponse; deliver: () => void; } +const animationFrames = new Set>(); let responses: HeldResponse[]; let responseWaiters: ((response: HeldResponse) => void)[]; let terminationPromises: Promise[]; @@ -147,10 +148,18 @@ beforeEach(async () => { responses = []; responseWaiters = []; terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => - setImmediate(() => callback(0)), - ); - vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frame = setImmediate(() => { + animationFrames.delete(frame); + callback(0); + }); + animationFrames.add(frame); + return frame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => { + animationFrames.delete(frame); + clearImmediate(frame); + }); vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); pool = new WorkerPoolManager( // Adapt browser transport only; Pierre's real worker produces each response. @@ -181,6 +190,9 @@ afterEach(async () => { renderer?.cleanUp(); pool?.terminate(); await Promise.all(terminationPromises); + // Worker termination does not cancel the pool's queued stats frame. + for (const frame of animationFrames) clearImmediate(frame); + animationFrames.clear(); vi.unstubAllGlobals(); }); From f9754c346bcda6fe2d172bfff57eaa2a6eeb5f05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:03:28 -0700 Subject: [PATCH 28/29] test(web): isolate asset hooks in preview view tests --- apps/web/src/components/preview/PreviewView.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9456daef72d8..626fe03e77b8 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -60,6 +60,13 @@ vi.mock("~/state/session", () => ({ readPreparedConnection: mocks.readPreparedConnection, })); +// File-preview errors share a module with asset hooks. Keep the pure URL resolver +// without importing those hooks and their environment runtime into chrome tests. +vi.mock("~/assets/assetUrls", async () => { + const { resolveAssetUrl } = await import("@t3tools/client-runtime/state/assets"); + return { resolveAssetUrl }; +}); + // Stubbed at the direct dependency rather than letting the real module pull in // `useSettings` -> `state/server`, which would drag the whole settings and // connection graph into a test that only cares about the browser chrome. From 8336893a26529e1fe2cd30ddd690e1c15ade60e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:22:36 -0700 Subject: [PATCH 29/29] refactor(web): resolve file access through one hook The same resolveFilesystemReadAccess block was inlined in four files. Using the existing useFilesystemReadAccess hook drops the copies and stops useAssetUrls and the read-only editor banner from treating a pending grant as a denial. Co-Authored-By: Claude Fable 5 --- apps/web/src/assets/assetUrls.test.ts | 22 +++++++------- apps/web/src/assets/assetUrls.ts | 20 +++---------- apps/web/src/components/DiffPanel.tsx | 15 ++-------- .../src/components/files/FilePreviewPanel.tsx | 17 +++-------- apps/web/src/state/queries.ts | 30 ++----------------- docs/internals/environment-auth.md | 4 ++- 6 files changed, 28 insertions(+), 80 deletions(-) diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts index f32dc4ef5dba..cd977ac3f2e8 100644 --- a/apps/web/src/assets/assetUrls.test.ts +++ b/apps/web/src/assets/assetUrls.test.ts @@ -25,18 +25,20 @@ vi.mock("@effect/atom-react", () => ({ : AsyncResult.initial(false), })); vi.mock("~/state/session", () => ({ - environmentSession: { sessionStateAtom: () => ({}) }, usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), })); -vi.mock("~/state/presentation", () => ({ - useEnvironmentPresentation: () => ({ - isReady: true, - presentation: { connection: { phase: state.phase, error: null } }, - }), -})); -vi.mock("~/state/query", () => ({ - useEnvironmentQuery: () => ({ data: state.session, error: null }), -})); +vi.mock("~/state/filesystem", async () => { + const { resolveFilesystemReadAccess } = await import("@t3tools/client-runtime/state/filesystem"); + return { + useFilesystemReadAccess: () => + resolveFilesystemReadAccess({ + isCatalogReady: true, + connection: { phase: state.phase, error: null }, + session: state.session, + sessionError: null, + }), + }; +}); vi.mock("~/state/assets", () => ({ assetEnvironment: { createUrl: state.assetQuery }, })); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index b31814aa43d8..c1b09934f3f9 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,4 +1,3 @@ -import { AuthFilesystemReadScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { type AssetUrlState, @@ -7,15 +6,13 @@ import { resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; -import { environmentSession, usePreparedConnection, useEnvironmentScope } from "~/state/session"; -import { useEnvironmentPresentation } from "~/state/presentation"; -import { useEnvironmentQuery } from "~/state/query"; +import { useFilesystemReadAccess } from "~/state/filesystem"; +import { usePreparedConnection } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl, type AssetUrlState } from "@t3tools/client-runtime/state/assets"; @@ -24,16 +21,7 @@ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, ): AssetUrlState { - const fileAccessSession = useEnvironmentQuery( - environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), - ); - const fileEnvironment = useEnvironmentPresentation(environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); + const fileAccess = useFilesystemReadAccess(environmentId); const canReadResource = fileAccess.canReadFiles || (resource?._tag !== "workspace-file" && resource?._tag !== "media-file"); @@ -78,7 +66,7 @@ export function useAssetUrls( resources: ReadonlyArray, ): ReadonlyArray { const preparedConnection = usePreparedConnection(environmentId); - const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope); + const { canReadFiles } = useFilesystemReadAccess(environmentId); const allowedResources = useMemo( () => canReadFiles diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e12b250c590c..369aab6e93af 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,3 @@ -import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; -import { environmentSession } from "~/state/session"; -import { useEnvironmentPresentation } from "~/state/presentation"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -28,6 +25,7 @@ import { import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { useFilesystemReadAccess } from "~/state/filesystem"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; @@ -141,16 +139,7 @@ export default function DiffPanel({ }); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); - const fileAccessSession = useEnvironmentQuery( - activeThread ? environmentSession.sessionStateAtom(activeThread.environmentId) : null, - ); - const fileEnvironment = useEnvironmentPresentation(activeThread?.environmentId ?? null); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); + const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); const { canReadFiles } = fileAccess; const activeProjectId = activeThread?.projectId ?? null; const activeProject = useProject( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index fb272e325653..5b59c20f3967 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -6,7 +6,6 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { AuthFilesystemWriteScope } from "@t3tools/contracts"; -import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -15,6 +14,7 @@ import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { useFilesystemReadAccess } from "~/state/filesystem"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -49,9 +49,7 @@ import { buildFileReviewComment } from "~/reviewCommentContext"; import { assetEnvironment } from "~/state/assets"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; -import { useEnvironmentPresentation } from "~/state/presentation"; -import { useEnvironmentQuery } from "~/state/query"; -import { environmentSession, useEnvironmentScope } from "~/state/session"; +import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; @@ -993,14 +991,7 @@ export default function FilePreviewPanel({ // A file outside the workspace (an absolute path) is shown, never edited. const isHostFile = attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); - const fileAccessSession = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId)); - const fileEnvironment = useEnvironmentPresentation(environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); + const fileAccess = useFilesystemReadAccess(environmentId); const { canReadFiles } = fileAccess; const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const file = useProjectFileQuery( @@ -1251,7 +1242,7 @@ export default function FilePreviewPanel({ ) : null}
) : null} - {relativePath && !attachment && !isHostFile && !canWriteFiles ? ( + {relativePath && !attachment && !isHostFile && !canWriteFiles && !fileAccess.isPending ? (
Read-only connection. Unsaved edits are kept until write access returns.
diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index edbdce8aa0ae..039569ac260a 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -1,6 +1,3 @@ -import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; -import { environmentSession } from "./session"; -import { useEnvironmentPresentation } from "./presentation"; import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffTarget, @@ -27,6 +24,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useFilesystemReadAccess } from "./filesystem"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; @@ -273,18 +271,7 @@ export function useProjectPathSearch( [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); - const fileAccessSession = useEnvironmentQuery( - debouncedTarget.environmentId === null - ? null - : environmentSession.sessionStateAtom(debouncedTarget.environmentId), - ); - const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); + const fileAccess = useFilesystemReadAccess(debouncedTarget.environmentId); const { canReadFiles } = fileAccess; const searchTarget = debouncedTarget.environmentId !== null && @@ -338,18 +325,7 @@ interface ProjectContentSearchTarget { export function useProjectContentSearch(target: ProjectContentSearchTarget) { const hasTarget = target.environmentId !== null && target.cwd !== null; - const fileAccessSession = useEnvironmentQuery( - target.environmentId === null - ? null - : environmentSession.sessionStateAtom(target.environmentId), - ); - const fileEnvironment = useEnvironmentPresentation(target.environmentId); - const fileAccess = resolveFilesystemReadAccess({ - isCatalogReady: fileEnvironment.isReady, - connection: fileEnvironment.presentation?.connection ?? null, - session: fileAccessSession.data, - sessionError: fileAccessSession.error, - }); + const fileAccess = useFilesystemReadAccess(target.environmentId); const canReadFiles = hasTarget && fileAccess.canReadFiles; const isCheckingAccess = hasTarget && fileAccess.isPending; // Whitespace is significant in content queries; trimming is only used to diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 2239432ae6dd..8558f39082c2 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -57,7 +57,9 @@ file's identity when serving it, so atomic replacement requires a new URL while editing the same file in place does not. An HTML file authorized this way cannot load sibling assets; directory-scoped workspace previews are a separate grant. Clients should share the authored file reference so they do not disclose the -temporary URL's credential. +temporary URL's credential. `filesystem:read` is checked when the URL is minted, +not when it is served: a URL issued before the grant was revoked keeps working +until it expires, and it is not bound to the session that minted it. Host videos can change in place. Their [HTTP responses](../../apps/server/src/http.ts) omit cache validators because file