diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca8fd9c61415..0d353495c965 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,3 +1,5 @@ +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"; import type { MenuAction } from "@react-native-menu/menu"; @@ -36,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 { @@ -259,14 +262,15 @@ function useThreadFilesWorkspace(params: { }; } -function FilesUnavailable() { +function FilesUnavailable({ + detail = "This thread does not have an active workspace path.", +}: { + detail?: string; +}) { return ( - + ); } @@ -313,8 +317,19 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { props.route.params, ); const revealedInspectorRef = useRef(false); + const fileAccessSession = useEnvironmentQuery( + environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, + ); + const fileEnvironment = useEnvironmentPresentation(environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const entriesQuery = useEnvironmentQuery( - environmentId !== null && cwd !== null && !fileInspector.supported + canReadFiles && environmentId !== null && cwd !== null && !fileInspector.supported ? projectEnvironment.listEntries({ environmentId, input: { cwd }, @@ -406,6 +421,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { return ; } + if (!canReadFiles) { + if (fileAccess.isPending) { + return ; + } + return ( + + ); + } if (cwd === null) { return ; } @@ -517,7 +540,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} ; } + if (!canReadFiles) { + if (fileAccess.isPending) { + return ; + } + return ( + + ); + } if (cwd === null) { return ; } @@ -926,7 +972,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..60c7fc2670e8 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 { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; +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"; @@ -15,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"; @@ -33,11 +36,24 @@ export function ThreadFileNavigatorPane(props: { const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); + const fileAccessSession = useEnvironmentQuery( + environmentSession.sessionStateAtom(props.environmentId), + ); + const fileEnvironment = useEnvironmentPresentation(props.environmentId); + const fileAccess = resolveFilesystemReadAccess({ + isCatalogReady: fileEnvironment.isReady, + connection: fileEnvironment.presentation?.connection ?? null, + session: fileAccessSession.data, + sessionError: fileAccessSession.error, + }); + const { canReadFiles } = fileAccess; const entriesQuery = useEnvironmentQuery( - projectEnvironment.listEntries({ - environmentId: props.environmentId, - input: { cwd: props.cwd }, - }), + canReadFiles + ? projectEnvironment.listEntries({ + environmentId: props.environmentId, + input: { cwd: props.cwd }, + }) + : null, ); const entriesData = entriesQuery.data as ProjectListEntriesResult | null; const handlePreviewFile = useCallback( @@ -71,8 +87,14 @@ 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 +792,19 @@ function FolderBrowser(props: { () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), [browsePath.directoryPath], ); + 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( - browseInput === null + !canReadFiles || browseInput === null ? null : filesystemEnvironment.browse({ environmentId: props.environment.environmentId, @@ -808,9 +826,12 @@ function FolderBrowser(props: { return ( <> Browse folders + {!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/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 new file mode 100644 index 000000000000..c7791c351cfd --- /dev/null +++ b/apps/mobile/src/features/review/useReviewSections.test.ts @@ -0,0 +1,207 @@ +import { + AuthFilesystemReadScope, + CheckpointRef, + EnvironmentId, + MessageId, + ThreadId, + TurnId, + type AuthSessionState, + type OrchestrationCheckpointSummary, +} 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, + sessionAtom: {}, + effects: [] as Array<() => void>, + checkpoints: [] as ReadonlyArray, +})); + +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: (effect: () => void) => state.effects.push(effect), + useMemo: (factory: () => A) => factory(), +})); +vi.mock("../../state/session", () => ({ + 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 }), +})); +vi.mock("../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ selectedThreadCwd: "/repo" }), +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (atom: unknown) => ({ + data: atom === state.sessionAtom ? state.session : null, + error: atom === state.sessionAtom ? state.sessionError : 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() }), +})); +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 { setReviewSelectedSectionId, type ReviewCacheForThread } from "./reviewState"; +import { useReviewSections } from "./useReviewSections"; + +beforeEach(() => { + vi.clearAllMocks(); + state.effects = []; + state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; + state.sessionError = null; + 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"; +function makeReviewCache( + kind: "working-tree" | "branch-range" = "working-tree", +): ReviewCacheForThread { + return { + threadKey: "environment:thread", + gitSections: [ + { + id: kind, + kind, + title: "Dirty worktree", + baseRef: "HEAD", + headRef: null, + diff: localDiff, + diffHash: "cached-local", + truncated: false, + }, + ], + turnDiffById: { "turn:1": checkpointDiff }, + selectedSectionId: `git:${kind}`, + asyncState: { loadingTurnIds: {}, error: null }, + expandedFileIdsBySection: {}, + revealedLargeFileIdsBySection: {}, + viewedFileIdsBySection: {}, + }; +} + +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; +} + +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.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"); +}); + +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"); +}); + +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("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()); + 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 87325490990c..9c984539b3b7 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,9 +1,12 @@ +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"; @@ -30,10 +33,21 @@ export function useReviewSections(input: { }) { const { environmentId, reviewCache, threadId } = input; const enabled = input.enabled ?? true; + 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( - enabled && environmentId !== undefined && selectedThreadCwd !== null + canReadFiles && enabled && environmentId !== undefined && selectedThreadCwd !== null ? reviewEnvironment.diffPreview({ environmentId, input: { cwd: selectedThreadCwd }, @@ -62,23 +76,29 @@ export function useReviewSections(input: { ) as Record, [readyCheckpoints], ); - const reviewSections = useMemo( - () => - buildReviewSectionItems({ - checkpoints: readyCheckpoints, - gitSections: reviewCache.gitSections, - turnDiffById: reviewCache.turnDiffById, - loadingTurnIds, - loadingGitSections: diffPreview.isPending, - }), - [ - 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) ?? @@ -172,8 +192,14 @@ export function useReviewSections(input: { ); return { - error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, - loadingGitDiffs: diffPreview.isPending, + error: + diffPreview.error ?? + activeTurnDiff.error ?? + reviewCache.asyncState.error ?? + (selectedSection === null && !fileAccess.isPending && !canReadFiles + ? (fileAccess.error ?? "This connection cannot read local diffs.") + : null), + loadingGitDiffs: fileAccess.isPending || diffPreview.isPending, loadingTurnIds, reviewSections, selectedSection, 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..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,3 +1,6 @@ +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"; import type { @@ -378,8 +381,23 @@ 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 t3ProjectFileQuery = useEnvironmentQuery( + const fileAccessSession = useEnvironmentQuery( selectedProject !== null && selectedProject.workspaceRoot !== "" + ? 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 !== "" && fileAccess.isPending; + const t3ProjectFileQuery = useEnvironmentQuery( + canReadFiles && selectedProject !== null && selectedProject.workspaceRoot !== "" ? projectEnvironment.readFile({ environmentId: selectedProject.environmentId, input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, @@ -403,6 +421,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/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts new file mode 100644 index 000000000000..b9a69237edd3 --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -0,0 +1,265 @@ +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)( + "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) => { + 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..c3c6b425139a 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,34 @@ 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 = + 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, @@ -40,11 +77,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 +93,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 +157,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 +174,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/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 400bdb6b705a..f0e649b5ca4f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -1,57 +1,56 @@ 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 } 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 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 = + 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( - environmentId === null || resource === null + !canReadResource || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); - const shared = assetUrlStateFromResult( - result, - preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, - ); + const shared = !canReadResource + ? fileAccess.isPending + ? { _tag: "Loading" as const } + : { _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 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 0c0da1f847d5..d720322a51df 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,3 +1,6 @@ +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, @@ -253,25 +256,48 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { [target.cwd, target.environmentId, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); - const result = useEnvironmentQuery( + 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 - ? projectEnvironment.searchEntries({ + 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: result.error, - 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/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 a68aae5e0bf1..413bcda37c5e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1344,6 +1344,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; @@ -2465,7 +2475,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", @@ -2534,7 +2545,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<{ @@ -2599,7 +2611,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); @@ -2646,13 +2659,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); @@ -2692,7 +2707,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); @@ -2729,7 +2745,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); @@ -6878,6 +6895,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.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 84ff979e4e89..b31814aa43d8 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, @@ -6,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 } 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"; @@ -20,12 +24,26 @@ 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 canReadResource = + fileAccess.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: fileAccess.isPending ? "Loading" : "Failure" }; return assetUrlStateFromResult( result, preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, @@ -60,21 +78,32 @@ 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], - ); + 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/ChatMarkdown.assets.test.tsx b/apps/web/src/components/ChatMarkdown.assets.test.tsx new file mode 100644 index 000000000000..5608b945db0a --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.assets.test.tsx @@ -0,0 +1,95 @@ +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, + useEnvironmentScope: () => 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 33b2b086d8fc..89f66134b140 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,4 @@ -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -2409,7 +2409,12 @@ 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/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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0eaf4bf02570..696bf3dc1564 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -30,6 +30,7 @@ import { import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + AuthFilesystemReadScope, type DesktopWslState, type EnvironmentId, type EnvironmentMachineKind, @@ -79,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"; @@ -991,11 +992,14 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const browseQuery = useEnvironmentQuery( + const browseAccess = useFilesystemReadAccess(browseEnvironmentId); + const hasBrowseTarget = isBrowsing && - browsePath.directoryPath.length > 0 && - browseEnvironmentId !== null && - !relativePathNeedsActiveProject + browsePath.directoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject; + const browseQuery = useEnvironmentQuery( + browseAccess.canReadFiles && hasBrowseTarget ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { @@ -1006,7 +1010,11 @@ function OpenCommandPaletteDialog(props: { : null, ); const browseResult = browseQuery.data; - const isBrowsePending = browseQuery.isPending; + const isBrowsePending = hasBrowseTarget && (browseAccess.isPending || browseQuery.isPending); + 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( () => @@ -1033,7 +1041,10 @@ 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; } @@ -2245,6 +2256,7 @@ function OpenCommandPaletteDialog(props: { const willCreateProjectPath = canSubmitBrowsePath && !isBrowsePending && + browseAccessError === null && query.trim().length > 0 && !hasHighlightedBrowseItem && (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); @@ -2669,6 +2681,15 @@ function OpenCommandPaletteDialog(props: { ) : null} + {browseAccessError ? ( +
+ {browseAccessError} +
+ ) : isBrowsePending && browseResult === null ? ( +
+ Loading folders... +
+ ) : null} No completed turns yet. + ) : selectedTurnId === null && !canReadFiles ? ( + fileAccess.isPending ? ( + + ) : ( +
+ {fileAccess.error ?? "This connection cannot read local diffs."} +
+ ) ) : ( <>
diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 9746857a8cac..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 type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthFilesystemWriteScope, + type EnvironmentId, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -32,6 +36,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 +52,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 +91,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const openSaveDialog = () => { + if (!canWriteFiles) return; if (!workspaceRoot) { toastManager.add( stackedThreadToast({ @@ -101,7 +108,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 +170,10 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ {isCopied ? "Copied!" : "Copy to clipboard"} Download as markdown - + Save to workspace @@ -244,7 +254,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..fb272e325653 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -5,6 +5,8 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; +import { AuthFilesystemWriteScope } from "@t3tools/contracts"; +import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, @@ -47,6 +49,9 @@ 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"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; @@ -988,6 +993,16 @@ 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 { canReadFiles } = fileAccess; + const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope); const file = useProjectFileQuery( environmentId, cwd, @@ -1072,7 +1087,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 +1109,31 @@ export default function FilePreviewPanel({ }), ); })(); - }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); + }, [ + absolutePath, + canReadFiles, + createAssetUrl, + cwd, + environmentHttpBaseUrl, + openPreview, + threadRef, + ]); + + if (attachment === undefined && !canReadFiles) { + if (fileAccess.isPending) { + return ( +
+ + Checking file access... +
+ ); + } + return ( +
+ {fileAccess.error ?? "This connection cannot read host files."} +
+ ); + } return (
@@ -1212,6 +1251,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 +1320,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 ? ( 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(); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 0874bbb1e662..9556cf51f633 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -93,6 +93,30 @@ describe("FileSaveCoordinator", () => { 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..aa907cdc7753 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.ts @@ -2,9 +2,11 @@ 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; + /** Return false when another editor has newer unsaved contents. */ + readonly onConfirmed: (contents: string) => boolean | void; } export class FileSaveCoordinator { @@ -49,20 +51,24 @@ 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; 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 6486e016f007..a702371e8b93 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -1,23 +1,165 @@ 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"; +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"; import { clearProjectFileQueryData, confirmProjectFileQueryData, getOptimisticProjectFileQueryData, + getUnsavedProjectFileQueryData, resolveProjectFileQueryData, setProjectFileQueryData, } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-project-files-query-test"); +const optimisticFile = projectEnvironment.optimisticFile({ + environmentId, + cwd: "/repo", + 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(); }); + it("resumes an unsaved draft after closing the preview and restoring write access", async () => { + vi.stubGlobal("window", {}); + vi.useFakeTimers(); + const closePreview = appAtomRegistry.mount(optimisticFile); + 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(); + closePreview(); + await vi.runAllTimersAsync(); + drainRegistryTasks(); + + 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(); + drainRegistryTasks(); + expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); + }); + + it("releases a retained unsaved draft when explicitly cleared", () => { + setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); + setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft"); + drainRegistryTasks(); + expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( + "latest draft", + ); + + clearProjectFileQueryData(environmentId, "/repo", "convex.json"); + 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/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 8edf982a030a..16732ead934b 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -1,12 +1,33 @@ import { + AuthFilesystemReadScope, EnvironmentId, + type AuthSessionState, type ProjectListEntriesResult, type ProjectReadFileResult, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +const authorizationMocks = vi.hoisted(() => ({ + sessionAtom: null as Atom.Atom< + AsyncResult.AsyncResult, Error> + > | null, + phase: "connected" as "connected" | "offline", +})); + +vi.mock("~/state/session", () => ({ + environmentSession: { sessionStateAtom: () => authorizationMocks.sessionAtom }, +})); + +vi.mock("~/state/presentation", () => ({ + useEnvironmentPresentation: () => ({ + isReady: true, + presentation: { connection: { phase: authorizationMocks.phase, error: null } }, + }), +})); + const projectMocks = vi.hoisted(() => ({ listEntries: vi.fn(), optimisticFile: vi.fn(), @@ -62,6 +83,7 @@ vi.mock("react", async (importOriginal) => { ...actual, useCallback: reactHooks.useCallback, useEffect: reactHooks.useEffect, + useMemo: (factory: () => A) => factory(), useRef: reactHooks.useRef, }; }); @@ -75,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"); @@ -111,12 +134,179 @@ async function flushEffects(): Promise { describe("project query refresh", () => { beforeEach(() => { + authorizationMocks.sessionAtom = Atom.make( + AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), + ); + authorizationMocks.phase = "connected"; projectMocks.listEntries.mockReset(); projectMocks.optimisticFile.mockReset(); projectMocks.readFile.mockReset(); reactHooks.reset(); }); + it("does not query or expose optimistic file contents without read permission", () => { + 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") })); + 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."); + 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; + } + }); + 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..215640b244b1 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -14,11 +14,15 @@ 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 { 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")); @@ -26,6 +30,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; @@ -54,7 +67,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, @@ -73,6 +90,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, @@ -89,6 +115,7 @@ export function confirmProjectFileQueryData( confirmedAgainst: appAtomRegistry.get(queryAtom), }; appAtomRegistry.set(atom, confirmed); + releaseUnsavedFile(atom); appAtomRegistry.refresh(queryAtom); void executeAtomQuery(appAtomRegistry, queryAtom, { reportDefect: false, @@ -116,7 +143,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 { @@ -129,14 +158,22 @@ export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, ): ProjectQueryState { - const atom = getProjectEntriesQueryAtom(environmentId, cwd); + const fileAccess = useFilesystemReadAccess(environmentId); + const { canReadFiles } = fileAccess; + 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), - isPending: result.waiting, + error: fileAccess.isPending + ? null + : canReadFiles + ? errorMessage(result) + : (fileAccess.error ?? "This connection cannot read host files."), + isPending: fileAccess.isPending || result.waiting, refresh, }; } @@ -182,11 +219,14 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { + const fileAccess = useFilesystemReadAccess(environmentId); + const { canReadFiles } = fileAccess; const isMedia = relativePath !== null && (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const isQueryEnabled = enabled && !isMedia; const atom = - enabled && !isMedia + canReadFiles && isQueryEnabled ? getProjectFileQueryAtom(environmentId, cwd, relativePath) : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); @@ -199,9 +239,14 @@ export function useProjectFileQuery( const optimisticFile = relativePath === null ? null : optimisticResult; return { - data: optimisticFile?.data ?? data, - error: errorMessage(result), - isPending: result.waiting, + data: canReadFiles ? (optimisticFile?.data ?? data) : null, + 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/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..2ac6ee67263b 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,15 +35,15 @@ 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({ environmentId, input: { cwd, relativePath, contents: nextContents }, }), - onConfirmed: (confirmedContents) => { - confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents); - }, + onConfirmed: (confirmedContents) => + confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents), }); coordinatorRef.current = coordinator; return () => { @@ -52,5 +57,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/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx new file mode 100644 index 000000000000..18d88b29b868 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -0,0 +1,307 @@ +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"; + +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(), + 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)( + "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) => { + 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 = deferred(); + const completed = deferred(); + 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..9e426497f2ea 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,44 @@ 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 = + 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."); + } + }, [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 +89,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 +108,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 +120,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 +135,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 +148,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 +177,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({ 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. 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/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 35e914ccdc52..c6d1beb6d49e 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/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, + }); +} 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..7cff8b1e5ada --- /dev/null +++ b/apps/web/src/state/queries.filesystem.test.ts @@ -0,0 +1,241 @@ +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, + 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) => ({ + ...(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 }, + projectContentSearch: (target: unknown) => { + state.contentRequests(target); + return state.contentAtom; + }, +})); +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" }] } + : atom === state.contentAtom + ? state.contentData + : null, + error: + atom === state.sessionAtom + ? state.sessionError + : atom === state.contentAtom + ? state.contentError + : null, + isPending: + atom === state.sessionAtom && + (state.sessionWaiting || (state.session === null && state.sessionError === null)), + refresh: vi.fn(), + }), +})); + +import { useProjectContentSearch, 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.sessionWaiting = false; + state.phase = "connected"; + state.contentRequests.mockClear(); + state.contentError = null; +}); + +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, + }); +}); + +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 094db94c4dcf..edbdce8aa0ae 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -1,3 +1,6 @@ +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, @@ -270,12 +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 result = useEnvironmentQuery( + 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) - ? projectEnvironment.searchEntries({ + debouncedTarget.cwd !== null && + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) + ? { environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, @@ -284,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: result.error, + 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, }; @@ -312,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 @@ -337,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, 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). diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 8de5ca89a139..1c3d419b0fe8 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -164,6 +164,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/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 44e3df6ab267..2fc46a760e17 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -1,12 +1,119 @@ 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("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({ + isCatalogReady: true, + connection: { 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({ + isCatalogReady: true, + 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({ + isCatalogReady: true, + connection: { phase: "error", error: "The relay is unavailable." }, + session: null, + sessionError: null, + }), + ).toEqual({ canReadFiles: false, isPending: false, error: "The relay is unavailable." }); + }); + + 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] }, + { authenticated: false, scopes: [AuthFilesystemReadScope] }, + ] as const)("does not infer file access from an ungranted session", (session) => { + expect( + resolveFilesystemReadAccess({ + isCatalogReady: true, + 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..c82c54567cd4 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,38 @@ import { } from "./projects.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +export function resolveFilesystemReadAccess(input: { + readonly isCatalogReady: boolean; + 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) { + // 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"; + 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) : ""; diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts new file mode 100644 index 000000000000..12cf77411c73 --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,14 @@ +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; 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) ); }