diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts new file mode 100644 index 000000000000..2704f5c521ad --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -0,0 +1,267 @@ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { isValidElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Set(), + otherScopes: new Set(), + baseDirectory: "", + projects: [] as string[], + createRequests: [] as { environmentId: string; workspaceRoot: string }[], +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], + useRef: (current: unknown) => ({ current }), + useEffect: () => {}, +})); +vi.mock("react-native", () => ({ + ActivityIndicator: "ActivityIndicator", + Alert: { alert: () => {} }, + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ dispatch: () => {} }), + CommonActions: { reset: (input: unknown) => input }, + StackActions: {}, +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" })); +vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); +vi.mock("../../components/EnvironmentMachineSymbol", () => ({ + EnvironmentMachineSymbol: "EnvironmentMachineSymbol", +})); +vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" })); +vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" })); +vi.mock("../../state/session", () => ({ + useEnvironmentScope: (environmentId: string, scope: string) => + (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope), + readEnvironmentScope: (environmentId: string, scope: string) => + (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope), +})); +vi.mock("../../state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map([ + [ + "environment", + { + environment: { platform: { os: "linux" } }, + settings: { addProjectBaseDirectory: state.baseDirectory }, + }, + ], + [ + "other-environment", + { + environment: { platform: { os: "linux" } }, + settings: { addProjectBaseDirectory: state.baseDirectory }, + }, + ], + ]), +})); +vi.mock("../../state/use-remote-environment-registry", () => ({ + useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), + useRemoteConnectionStatus: () => ({ + connectedEnvironments: [ + { environmentId: "environment", connectionState: "connected" }, + { environmentId: "other-environment", connectionState: "connected" }, + ], + }), + useSavedRemoteConnections: () => ({ + savedConnectionsById: { + connection: { environmentId: "environment", environmentLabel: "Environment" }, + other: { environmentId: "other-environment", environmentLabel: "Other environment" }, + }, + }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), +})); +vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); +vi.mock("../../state/sourceControl", () => ({ + sourceControlEnvironment: { + cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { + NodeFS.mkdirSync(input.destinationPath); + return AsyncResult.success({ cwd: input.destinationPath }); + }, + }, +})); +vi.mock("../../state/projects", () => ({ + projectEnvironment: { + create: async ({ + environmentId, + input, + }: { + environmentId: string; + input: { workspaceRoot: string }; + }) => { + state.createRequests.push({ environmentId, workspaceRoot: input.workspaceRoot }); + const scopes = environmentId === "environment" ? state.scopes : state.otherScopes; + if (!scopes.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + } + state.projects.push(input.workspaceRoot); + return AsyncResult.success(undefined); + }, + }, +})); + +import { AddProjectDestinationScreen, AddProjectLocalFolderScreen } from "./AddProjectScreen"; + +function findAction(node: ReactNode, label: string): (() => unknown) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findAction(child, label); + if (action) return action; + } + return null; + } + if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + return null; + } + if (node.props.label === label) return node.props.onPress ?? null; + return findAction(node.props.children, label); +} + +function cloneAction() { + const action = findAction( + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + "Clone project", + ); + if (!action) throw new Error("Clone action missing"); + return action; +} + +function localProjectAction(environmentId = "environment") { + const action = findAction(AddProjectLocalFolderScreen({ environmentId }), "Add project"); + if (!action) throw new Error("Add project action missing"); + return action; +} + +describe("clone project permissions", () => { + beforeEach(async () => { + state.baseDirectory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"), + ); + state.scopes = new Set([AuthSourceControlWriteScope]); + state.otherScopes = new Set(); + state.projects = []; + state.createRequests = []; + }); + + afterEach(async () => { + await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true }); + }); + + it("does not leave a clone on disk when project creation is denied", async () => { + await cloneAction()(); + + expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); + expect(state.projects).toEqual([]); + }); + + it("clones and registers the project when both permissions are granted", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + await cloneAction()(); + + const destination = NodePath.join(state.baseDirectory, "repo"); + expect(NodeFS.existsSync(destination)).toBe(true); + expect(state.projects).toEqual([destination]); + }); + + it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])( + "rechecks %s before a retained clone action creates a directory", + async (scope) => { + state.scopes.add(AuthOrchestrationOperateScope); + const submit = cloneAction(); + state.scopes.delete(scope); + await submit(); + + expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); + expect(state.projects).toEqual([]); + }, + ); +}); + +describe("local project permissions", () => { + beforeEach(() => { + state.baseDirectory = "/workspace/project"; + state.scopes = new Set(); + state.otherScopes = new Set(); + state.projects = []; + state.createRequests = []; + }); + + it.each([false, true])( + "does not dispatch project creation without the current grant (revoked: %s)", + async (revokeBeforeSubmit) => { + if (revokeBeforeSubmit) state.scopes.add(AuthOrchestrationOperateScope); + const submit = localProjectAction(); + state.scopes.delete(AuthOrchestrationOperateScope); + + await submit(); + + expect(state.createRequests).toEqual([]); + expect(state.projects).toEqual([]); + }, + ); + + it("adds a typed local path with only task-operation permission", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + + await localProjectAction()(); + + expect(state.createRequests).toEqual([ + { environmentId: "environment", workspaceRoot: state.baseDirectory }, + ]); + expect(state.projects).toEqual([state.baseDirectory]); + }); + + it("uses a grant added while the local-folder form is open", async () => { + const submit = localProjectAction(); + state.scopes.add(AuthOrchestrationOperateScope); + + await submit(); + + expect(state.projects).toEqual([state.baseDirectory]); + }); + + it.each([false, true])( + "uses the selected environment's grant (allowed: %s)", + async (allowSelectedEnvironment) => { + (allowSelectedEnvironment ? state.otherScopes : state.scopes).add( + AuthOrchestrationOperateScope, + ); + + await localProjectAction("other-environment")(); + + expect(state.createRequests).toEqual( + allowSelectedEnvironment + ? [{ environmentId: "other-environment", workspaceRoot: state.baseDirectory }] + : [], + ); + expect(state.projects).toEqual(allowSelectedEnvironment ? [state.baseDirectory] : []); + }, + ); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index cc1e8f4e5799..c0bea23051fe 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -32,6 +32,8 @@ import { isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, CommandId, type EnvironmentId, type EnvironmentMachineKind, @@ -53,6 +55,7 @@ import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -464,6 +467,15 @@ export function AddProjectSourceScreen() { const navigation = useNavigation(); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); + const canWriteSourceControl = useEnvironmentScope( + selectedEnvironment?.environmentId ?? null, + AuthSourceControlWriteScope, + ); + const canCreateProject = useEnvironmentScope( + selectedEnvironment?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canCloneProject = canWriteSourceControl && canCreateProject; const discoveryState = useEnvironmentQuery( selectedEnvironment === null ? null @@ -530,7 +542,11 @@ export function AddProjectSourceScreen() { } isFirst + disabled={!canCreateProject} onPress={() => navigation.dispatch( StackActions.push("AddProjectLocal", { @@ -554,11 +571,13 @@ export function AddProjectSourceScreen() { key={candidate} source={candidate} selectedEnvironmentId={selectedEnvironment.environmentId} - ready={readiness[candidate].ready} + ready={canCloneProject && readiness[candidate].ready} hint={ - readiness[candidate].ready - ? addProjectRemoteSourcePathHint(candidate) - : (readiness[candidate].hint ?? "") + !canCloneProject + ? "This connection cannot clone projects." + : readiness[candidate].ready + ? addProjectRemoteSourcePathHint(candidate) + : (readiness[candidate].hint ?? "") } isFirst={false} /> @@ -581,7 +600,13 @@ function useCreateProject(environment: EnvironmentOption | null) { return useCallback( async (workspaceRoot: string) => { - if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; + if ( + !environment || + !canCreateProjectInEnvironment(environment.connectionState) || + !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) + ) { + return; + } const existing = findExistingAddProject({ projects, @@ -841,6 +866,10 @@ function FolderBrowser(props: { export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); + const canCreateProject = useEnvironmentScope( + environment?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); const createProject = useCreateProject(environment); const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(environment); @@ -873,15 +902,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st {error ? : null} {environment ? ( <> - void submitPath()} - /> + {!canCreateProject ? ( + + ) : null} + void submitPath()} + disabled={!canCreateProject || isBrowseNavigating || isSubmitting} + onPress={submitPath} loading={isSubmitting} /> (null); const submitPath = useCallback(async () => { - if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; + if ( + !environment || + !readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) || + !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) || + !remoteUrl || + isBrowseNavigating || + isSubmitting + ) { + return; + } setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -976,17 +1022,18 @@ export function AddProjectDestinationScreen(props: { ) : null} {environment ? ( <> - void submitPath()} - /> + void submitPath()} + disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl} + onPress={submitPath} loading={isSubmitting} /> + {!canCloneProject ? ( + + This connection cannot clone projects. + + ) : null} { - if (selectingBranchNameRef.current !== null) { + const needsCheckout = shouldCheckoutNewTaskBranch({ + branchIsCurrent: branch.current, + branchWorktreePath: branch.worktreePath, + workspaceMode: flow.workspaceMode, + }); + if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) { return; } selectingBranchNameRef.current = branch.name; @@ -258,11 +268,6 @@ export function NewTaskBranchPickerRouteScreen() { try { let selectedBranch = branch; - const needsCheckout = shouldCheckoutNewTaskBranch({ - branchIsCurrent: branch.current, - branchWorktreePath: branch.worktreePath, - workspaceMode: flow.workspaceMode, - }); if (needsCheckout && flow.selectedProject) { setSwitchingBranchName(branch.name); const result = await switchRef({ @@ -309,6 +314,7 @@ export function NewTaskBranchPickerRouteScreen() { } }, [ + canWriteSourceControl, flow.selectBranch, flow.selectedProject, flow.setBranchQuery, @@ -323,7 +329,15 @@ export function NewTaskBranchPickerRouteScreen() { ), [ + canWriteSourceControl, flow.filteredBranches.length, flow.selectedProject, + flow.workspaceMode, selectBranch, selectedBranchName, switchingBranchName, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 31b65f49353a..1843d498a44d 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -1,4 +1,5 @@ import { + AuthSourceControlWriteScope, EnvironmentId, type GitRunStackedActionResult, type ProjectScript, @@ -15,6 +16,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { useEnvironmentScope } from "../../state/session"; import { basename, getTerminalStatusLabel, @@ -109,6 +111,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { function useThreadGitControlModel(props: ThreadGitMenuProps) { const navigation = useNavigation(); const environmentId = props.environmentId; + const canWriteSourceControl = useEnvironmentScope( + environmentId ? EnvironmentId.make(String(environmentId)) : null, + AuthSourceControlWriteScope, + ); const threadId = props.threadId; const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; @@ -118,18 +124,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const isDefaultRef = gitStatus?.isDefaultRef ?? false; - const quickAction = useMemo( - () => - isRepo - ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) - : { - label: "Git unavailable", - disabled: true, - kind: "show_hint" as const, - hint: "This workspace is not a git repository.", - }, - [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], - ); + const quickAction = useMemo(() => { + if (!isRepo) { + return { + label: "Git unavailable", + disabled: true, + kind: "show_hint" as const, + hint: "This workspace is not a git repository.", + }; + } + const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote); + return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action") + ? { + ...action, + disabled: true, + hint: "This connection cannot change source control.", + } + : action; + }, [busy, canWriteSourceControl, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo]); const quickActionHint = quickAction.disabled ? (quickAction.hint ?? "This action is unavailable.") @@ -159,6 +171,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -187,10 +200,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { await onRunAction(input); }, - [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], + [ + canWriteSourceControl, + environmentId, + gitStatus, + isDefaultRef, + onRunAction, + navigation, + threadId, + ], ); const runQuickAction = useCallback(async () => { + if (quickAction.disabled) return; if (quickAction.kind === "open_pr") { await openExistingPr(); return; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx new file mode 100644 index 000000000000..d2768b0deef7 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx @@ -0,0 +1,171 @@ +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + fields: [] as string[], + fieldIndex: 0, + canChangeThreadBranch: true, + navigations: 0, + result: Promise.resolve(null) as Promise, +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useState: (initial: string) => { + const index = state.fieldIndex++; + state.fields[index] ??= initial; + return [state.fields[index], (value: string) => (state.fields[index] = value)]; + }, +})); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ goBack: () => state.navigations++ }), +})); +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock("../../../components/AndroidScreenHeader", () => ({ + AndroidSheetHeader: "AndroidSheetHeader", +})); +vi.mock("../../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); +vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" })); +vi.mock("../../../state/query", () => ({ + useEnvironmentQuery: () => ({ data: { refName: "main" } }), +})); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }), +})); +vi.mock("../../../state/use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: "/repo", + selectedThreadWorktreePath: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ + canChangeThreadBranch: state.canChangeThreadBranch, + onCreateSelectedThreadBranch: () => state.result, + onCreateSelectedThreadWorktree: () => state.result, + onCheckoutSelectedThreadBranch: () => state.result, + }), +})); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +type ControlProps = { + label?: string; + placeholder?: string; + onPress?: () => unknown; + onChangeText?: (value: string) => void; + value?: string; + children?: ReactNode; +}; + +function findControl(node: ReactNode, label: string): ControlProps | null { + if (Array.isArray(node)) { + for (const child of node) { + const control = findControl(child, label); + if (control) return control; + } + return null; + } + if (!isValidElement(node)) return null; + if ( + node.props.label === label || + node.props.placeholder === label || + (label === "checkout" && node.type === "Pressable") + ) { + return node.props; + } + return findControl(node.props.children, label); +} + +function control(label: string) { + state.fieldIndex = 0; + const found = findControl( + GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }), + label, + ); + if (!found) throw new Error(`Missing sheet control: ${label}`); + return found; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +describe("branch sheet operation completion", () => { + beforeEach(() => { + state.fields = []; + state.fieldIndex = 0; + state.canChangeThreadBranch = true; + state.navigations = 0; + control("feature/mobile-polish").onChangeText?.("feature/branch"); + control("main").onChangeText?.("release"); + control("feature/mobile-thread").onChangeText?.("feature/worktree"); + }); + + it.each(["Create & checkout", "Create worktree", "checkout"])( + "keeps the sheet and its input when %s cannot complete", + async (operation) => { + const result = deferred(); + state.result = result.promise; + const press = control(operation).onPress; + if (!press) throw new Error("Missing operation handler"); + state.canChangeThreadBranch = false; + const completion = press(); + result.resolve(null); + await result.promise; + await completion; + + expect(state.navigations).toBe(0); + expect(control("feature/mobile-polish").value).toBe("feature/branch"); + expect(control("main").value).toBe("release"); + expect(control("feature/mobile-thread").value).toBe("feature/worktree"); + }, + ); + + it.each(["Create & checkout", "Create worktree", "checkout"])( + "dismisses after accepted %s completion even if the source grant changes", + async (operation) => { + const result = deferred(); + state.result = result.promise; + const press = control(operation).onPress; + if (!press) throw new Error("Missing operation handler"); + const completion = press(); + state.canChangeThreadBranch = false; + result.resolve( + operation === "Create worktree" + ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } } + : { refName: operation === "checkout" ? "main" : "feature/branch" }, + ); + await result.promise; + await completion; + + expect(state.navigations).toBe(1); + expect(control("feature/mobile-polish").value).toBe( + operation === "Create & checkout" ? "" : "feature/branch", + ); + expect(control("feature/mobile-thread").value).toBe( + operation === "Create worktree" ? "" : "feature/worktree", + ); + }, + ); +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index c66fc7887624..441ab2d39c04 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -27,6 +27,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canChangeThreadBranch } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -68,6 +69,11 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerClassName="gap-4 px-5 pt-2" > + {!canChangeThreadBranch ? ( + + This connection cannot change this thread's branch or worktree. + + ) : null} New branch @@ -82,14 +88,15 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { icon="plus" label="Create & checkout" tone="primary" - disabled={busy || newBranchName.trim().length === 0} - onPress={() => { + disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} + onPress={async () => { + if (!canChangeThreadBranch) return; const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); + const result = await gitActions.onCreateSelectedThreadBranch(branch); + if (result === null) return; + setNewBranchName(""); + navigation.goBack(); }} /> @@ -115,18 +122,23 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create worktree" tone="primary" disabled={ + !canChangeThreadBranch || busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 } - onPress={() => { + onPress={async () => { + if (!canChangeThreadBranch) return; const baseBranch = worktreeBaseBranch.trim(); const newBranch = worktreeBranchName.trim(); if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); + const result = await gitActions.onCreateSelectedThreadWorktree({ + baseBranch, + newBranch, }); + if (result === null) return; + setWorktreeBranchName(""); + navigation.goBack(); }} /> @@ -162,11 +174,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", branch.current ? "border-subtle-strong" : "border-border", )} - disabled={busy || disabled} - onPress={() => { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); + disabled={!canChangeThreadBranch || busy || disabled} + onPress={async () => { + if (!canChangeThreadBranch) return; + const result = await gitActions.onCheckoutSelectedThreadBranch(branch.name); + if (result === null) return; + navigation.goBack(); }} > diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..3fb5473c87a9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -26,6 +26,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -53,6 +54,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { const runCommitAction = useCallback( async (featureBranch: boolean) => { + if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return; const commitMessage = dialogCommitMessage.trim(); navigation.goBack(); await gitActions.onRunSelectedThreadGitAction({ @@ -62,7 +64,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), }); }, - [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles], + [ + allSelected, + canWriteSourceControl, + canChangeThreadBranch, + dialogCommitMessage, + gitActions, + navigation, + selectedFiles, + ], ); return ( @@ -208,12 +218,17 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { /> + {!canWriteSourceControl ? ( + + This connection cannot change source control. + + ) : null} void runCommitAction(true)} /> @@ -222,7 +237,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { icon="checkmark.circle" label="Commit" tone="primary" - disabled={noneSelected || busy} + disabled={!canWriteSourceControl || noneSelected || busy} onPress={() => void runCommitAction(false)} /> diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index cddf1c614bd0..5a28840a8650 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -29,6 +29,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { const insets = useSafeAreaInsets(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const params = props.route.params; @@ -56,17 +57,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { ); const continuePendingAction = useCallback(async () => { - if (!confirmAction) return; + if (!canWriteSourceControl || !confirmAction) return; navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); await gitActions.onRunSelectedThreadGitAction({ action: confirmAction, ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), }); - }, [confirmAction, environmentId, gitActions, params, navigation, threadId]); + }, [ + canWriteSourceControl, + confirmAction, + environmentId, + gitActions, + params, + navigation, + threadId, + ]); const movePendingActionToFeatureBranch = useCallback(async () => { - if (!confirmAction) return; + if (!canChangeThreadBranch || !confirmAction) return; navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); if (includesCommit) { @@ -88,9 +97,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { branch.isRemote ? Result.failVoid : Result.succeed(branch.name), ), ); - await gitActions.onCreateSelectedThreadBranch(newBranchName); + const created = await gitActions.onCreateSelectedThreadBranch(newBranchName); + if (created === null) return; await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); }, [ + canChangeThreadBranch, confirmAction, gitActions, gitState.selectedThreadBranches, @@ -122,15 +133,22 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { + {!canWriteSourceControl ? ( + + This connection cannot change source control. + + ) : null} void continuePendingAction()} /> void movePendingActionToFeatureBranch()} /> diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 5aefccb4baff..c499a6ee6b56 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -53,6 +53,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); + const { canWriteSourceControl, canChangeThreadBranch } = gitActions; const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; @@ -83,15 +84,21 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const sheetMenuItems = useMemo( () => menuItems.map((item) => ({ - item, - disabledReason: getGitActionDisabledReason({ - item, - gitStatus: gitStatus.data, - isBusy: busy, - hasOriginRemote: hasPrimaryRemote, - }), + item: { + ...item, + disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"), + }, + disabledReason: + !canWriteSourceControl && item.kind !== "open_pr" + ? "This connection cannot change source control." + : getGitActionDisabledReason({ + item, + gitStatus: gitStatus.data, + isBusy: busy, + hasOriginRemote: hasPrimaryRemote, + }), })), - [busy, gitStatus.data, hasPrimaryRemote, menuItems], + [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems], ); useEffect(() => { @@ -111,6 +118,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const runActionWithPrompt = useCallback( async (input: GitActionRequestInput) => { + if (!canWriteSourceControl) return; const confirmableAction = input.action === "push" || input.action === "create_pr" || @@ -142,7 +150,16 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { } await gitActions.onRunSelectedThreadGitAction(input); }, - [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], + [ + canWriteSourceControl, + environmentId, + gitActions, + gitStatus.data, + isDefaultRef, + isInspector, + navigation, + threadId, + ], ); const onPressMenuItem = useCallback( @@ -152,6 +169,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { await openExistingPr(); return; } + if (!canWriteSourceControl) return; if (item.dialogAction === "commit") { navigation.navigate("GitCommit", { environmentId: String(environmentId), @@ -167,7 +185,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { await runActionWithPrompt({ action: "create_pr" }); } }, - [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], + [ + canWriteSourceControl, + environmentId, + openExistingPr, + navigation, + runActionWithPrompt, + threadId, + ], ); // Status facts live on the relevant rows instead of crowding the header @@ -249,8 +274,12 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { void gitActions.onPullSelectedThreadBranch()} /> @@ -274,7 +303,11 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { navigation.navigate("GitBranches", { diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts new file mode 100644 index 000000000000..63f8c31a02de --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -0,0 +1,283 @@ +import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Set(), + primaryScopes: new Set(), + branch: "main", + worktrees: [] as string[], + thread: { + id: "thread", + environmentId: "environment", + branch: "main", + worktreePath: null as string | null, + }, + commits: 0, + pushes: 0, + metadataRequests: [] as { environmentId: string; branch: string }[], + results: [] as { type: string; description?: string }[], + afterGitAction: undefined as (() => void) | undefined, + statusRefreshes: 0, +})); + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useEffect: () => {}, +})); +vi.mock("./session", () => ({ + useEnvironmentScope: (environmentId: unknown, scope: string) => + (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), + readEnvironmentScope: (environmentId: unknown, scope: string) => + (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), +})); +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: state.thread, + selectedThreadProject: { workspaceRoot: "/repo" }, + }), +})); +vi.mock("./use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ + selectedThreadCwd: "/repo", + selectedThreadWorktreePath: null, + }), +})); +vi.mock("./queries", () => ({ + useBranches: () => ({ data: { refs: [] }, refresh: () => {} }), +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); +vi.mock("./use-vcs-action-state", () => ({ + showGitActionResult: (result: { type: string; description?: string }) => + state.results.push(result), +})); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" })); +vi.mock("./threads", () => ({ + threadEnvironment: { + updateMetadata: async ({ + environmentId, + input, + }: { + environmentId: string; + input: { branch: string; worktreePath: string | null }; + }) => { + state.metadataRequests.push({ environmentId, branch: input.branch }); + if (!state.scopes.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Task operation denied"))); + } + Object.assign(state.thread, input); + return AsyncResult.success(undefined); + }, + }, +})); +vi.mock("./vcs", () => ({ + vcsEnvironment: { + refreshStatus: async () => { + state.statusRefreshes += 1; + return AsyncResult.success({ refName: state.branch }); + }, + switchRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + state.afterGitAction?.(); + return AsyncResult.success({ refName: state.branch }); + }, + createRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + state.afterGitAction?.(); + return AsyncResult.success({ refName: state.branch }); + }, + createWorktree: async ({ input }: { input: { newRefName: string } }) => { + state.worktrees.push("/repo-worktree"); + state.afterGitAction?.(); + return AsyncResult.success({ + worktree: { path: "/repo-worktree", refName: input.newRefName }, + }); + }, + pull: async () => AsyncResult.success({ status: "pulled", refName: state.branch }), + }, + vcsActionManager: { + track: (_registry: unknown, _target: unknown, _operation: unknown, run: () => unknown) => run(), + runStackedAction: () => async (input: { action: string; featureBranch?: boolean }) => { + if (input.featureBranch) state.branch = "feature"; + if (input.action === "commit") state.commits += 1; + if (input.action === "push") state.pushes += 1; + state.afterGitAction?.(); + return AsyncResult.success({ + branch: input.featureBranch + ? { status: "created", name: "feature" } + : { status: "skipped_not_requested" }, + toast: { title: "Done", description: "Done", cta: { kind: "none" } }, + }); + }, + }, +})); + +import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; + +describe("thread Git mutation permissions", () => { + beforeEach(() => { + state.scopes = new Set([AuthSourceControlWriteScope]); + state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); + state.branch = "main"; + state.worktrees = []; + state.thread.branch = "main"; + state.thread.worktreePath = null; + state.commits = 0; + state.pushes = 0; + state.metadataRequests = []; + state.results = []; + state.afterGitAction = undefined; + state.statusRefreshes = 0; + }); + + it.each([false, true])( + "requires task permission before creating and attaching a worktree: %s", + async (canOperate) => { + if (canOperate) state.scopes.add(AuthOrchestrationOperateScope); + const actions = useSelectedThreadGitActions(); + const result = await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }); + expect(result).toEqual( + canOperate ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } : null, + ); + expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []); + expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null); + expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main"); + }, + ); + + it.each(["create", "checkout", "commit on new branch"] as const)( + "requires task permission before %s", + async (operation) => { + const actions = useSelectedThreadGitActions(); + if (operation === "create") { + expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); + } + if (operation === "checkout") + expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); + if (operation === "commit on new branch") + await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }); + expect(state.branch).toBe("main"); + expect(state.thread.branch).toBe("main"); + expect(state.commits).toBe(0); + }, + ); + + it("rechecks task permission when a retained menu callback runs", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + const actions = useSelectedThreadGitActions(); + state.scopes.delete(AuthOrchestrationOperateScope); + expect( + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }), + ).toBeNull(); + expect(state.worktrees).toEqual([]); + expect(state.thread.worktreePath).toBeNull(); + }); + + it("keeps ordinary commits and pushes available without task permission", async () => { + const actions = useSelectedThreadGitActions(); + await actions.onRunSelectedThreadGitAction({ action: "commit" }); + await actions.onRunSelectedThreadGitAction({ action: "push" }); + expect(state.commits).toBe(1); + expect(state.pushes).toBe(1); + expect(state.thread.branch).toBe("main"); + expect(state.metadataRequests).toEqual([]); + }); + + it.each(["create", "checkout", "worktree", "commit"] as const)( + "does not send thread metadata after task permission is revoked during %s", + async (operation) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope); + const actions = useSelectedThreadGitActions(); + if (operation === "create") + expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); + if (operation === "checkout") + expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); + if (operation === "worktree") + expect( + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature", + }), + ).toBeNull(); + if (operation === "commit") + expect( + await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }), + ).toBeNull(); + if (operation === "worktree") expect(state.worktrees).toEqual(["/repo-worktree"]); + else expect(state.branch).toBe("feature"); + expect(state.thread.branch).toBe("main"); + expect(state.thread.worktreePath).toBeNull(); + expect(state.metadataRequests).toEqual([]); + expect(state.results.map((result) => result.type)).toEqual(["error"]); + }, + ); + + it("refreshes the worktree status when the metadata update is denied after checkout", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + // Git already switched the branch, so the sheet must show it even though the + // thread record could not be updated. + state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope); + expect( + await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("feature"), + ).toBeNull(); + expect(state.branch).toBe("feature"); + expect(state.statusRefreshes).toBe(1); + }); + + it("uses a newly granted task permission for a retained branch callback", async () => { + state.primaryScopes.clear(); + const actions = useSelectedThreadGitActions(); + state.scopes.add(AuthOrchestrationOperateScope); + await actions.onCreateSelectedThreadBranch("feature"); + expect(state.thread.branch).toBe("feature"); + expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); + }); + + it.each(["create", "checkout", "worktree"] as const)( + "returns the accepted %s result when only source-control permission is revoked after Git", + async (operation) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope); + const actions = useSelectedThreadGitActions(); + const result = + operation === "create" + ? await actions.onCreateSelectedThreadBranch("feature/task") + : operation === "checkout" + ? await actions.onCheckoutSelectedThreadBranch("feature/task") + : await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }); + expect(result).toEqual( + operation === "worktree" + ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } + : { refName: "feature/task" }, + ); + expect(state.thread.branch).toBe("feature/task"); + expect(state.metadataRequests).toHaveLength(1); + expect(state.results).toEqual([]); + }, + ); + + it("returns a successful checkout when the requested branch is already selected", async () => { + state.scopes.add(AuthOrchestrationOperateScope); + + expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({ + refName: "main", + }); + expect(state.branch).toBe("main"); + expect(state.thread.branch).toBe("main"); + expect(state.results).toEqual([]); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710d..451f378029fc 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -7,7 +7,12 @@ import { type VcsActionOperation, type VcsRef, } from "@t3tools/client-runtime/state/vcs"; -import type { GitRunStackedActionResult } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, @@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads"; import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; +import { readEnvironmentScope, useEnvironmentScope } from "./session"; import { setPendingConnectionError } from "./use-remote-environment-registry"; import { useAtomCommand } from "./use-atom-command"; import { showGitActionResult } from "./use-vcs-action-state"; @@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() { const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); const { selectedThread, selectedThreadProject } = useThreadSelection(); + const canWriteSourceControl = useEnvironmentScope( + selectedThread?.environmentId ?? null, + AuthSourceControlWriteScope, + ); + const canOperateThread = useEnvironmentScope( + selectedThread?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canChangeThreadBranch = canWriteSourceControl && canOperateThread; const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const runStackedAction = useAtomCommand( vcsActionManager.runStackedAction({ @@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }, ) => { + if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationOperateScope, + message: "This connection cannot update the thread's branch.", + }), + ), + ); + } return updateThreadMetadata({ environmentId: thread.environmentId, input: { @@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean }, ): Promise => { - if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { + if ( + !selectedThread || + !selectedThreadProject || + !selectedThreadCwd || + !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) || + (options?.changesThreadBranch === true && + !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)) + ) { return null; } @@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }; }): Promise> => { - if (input.nextThreadState) { - const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); - if (AsyncResult.isFailure(updateResult)) { - return AsyncResult.failure(updateResult.cause); - } - } + // The Git mutation already landed; refresh what the worktree shows even + // when the thread metadata update is denied, so the sheet does not keep + // displaying the previous branch. + const updateResult = input.nextThreadState + ? await updateThreadGitContext(input.thread, input.nextThreadState) + : AsyncResult.success(undefined); branchState.refresh(); await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); - return AsyncResult.success(undefined); + return AsyncResult.isFailure(updateResult) + ? AsyncResult.failure(updateResult.cause) + : AsyncResult.success(undefined); }, [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], ); const onCheckoutSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "switch_ref", "Switching branch", async ({ thread, cwd }) => { @@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -228,7 +263,7 @@ export function useSelectedThreadGitActions() { const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "create_ref", "Creating branch", async ({ thread, cwd }) => { @@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -261,7 +297,7 @@ export function useSelectedThreadGitActions() { const onCreateSelectedThreadWorktree = useCallback( async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "create_worktree", "Creating worktree", async ({ thread, project }) => { @@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], @@ -335,14 +372,6 @@ export function useSelectedThreadGitActions() { return result; } - showGitActionResult({ - type: "success", - title: result.value.toast.title, - description: result.value.toast.description, - prUrl: - result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, - }); - if (result.value.branch.status === "created" && result.value.branch.name) { const syncResult = await syncSelectedThreadBranchState({ thread, @@ -358,9 +387,16 @@ export function useSelectedThreadGitActions() { } else { await refreshSelectedThreadGitStatus({ quiet: true, cwd }); } + showGitActionResult({ + type: "success", + title: result.value.toast.title, + description: result.value.toast.description, + prUrl: + result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, + }); return result; }, - { managedExternally: true }, + { managedExternally: true, changesThreadBranch: input.featureBranch === true }, ); }, [ @@ -373,6 +409,8 @@ export function useSelectedThreadGitActions() { ); return { + canWriteSourceControl, + canChangeThreadBranch, refreshSelectedThreadGitStatus, refreshSelectedThreadBranches, onCheckoutSelectedThreadBranch, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d16bbbbe1471..263cf8bf840a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -8,6 +8,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, ORCHESTRATION_WS_METHODS, type AuthEnvironmentScope, @@ -74,14 +75,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, - [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSubmitReview]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsReplyToThread]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetThreadResolution]: AuthSourceControlWriteScope, + [WS_METHODS.pullRequestsSetReaction]: AuthSourceControlWriteScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, @@ -89,12 +90,12 @@ export const RPC_REQUIRED_SCOPES = { // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, - [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, - [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, - [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, + [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, + [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, @@ -111,16 +112,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, - [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, - [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, - [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, - [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, + [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, + [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, + [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, - [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, - [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, + [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, + [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 6123be6ccf25..be9c65ab98f6 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -10,6 +10,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthTerminalOperateScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, @@ -323,6 +324,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthAccessReadScope, AuthAccessWriteScope, AuthRelayReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 60d7a1b2749a..77d46a5c7e0e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,6 +7,8 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, AuthAdministrativeScopes, + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -5859,6 +5861,230 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "requires source-control write scope for mutations while keeping repository reads available", + () => + Effect.gen(function* () { + const calls: string[] = []; + const cloneResult = { + cwd: "/tmp/scoped-repository", + remoteUrl: "https://example.com/owner/repository.git", + repository: null, + }; + const actionResult = { + action: "push" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { status: "skipped_not_requested" as const }, + push: { status: "skipped_up_to_date" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Already up to date", + description: "No changes to push.", + cta: { kind: "none" as const }, + }, + }; + yield* buildAppUnderTest({ + layers: { + sourceControlRepositoryService: { + cloneRepository: () => + Effect.sync(() => { + calls.push("clone"); + return cloneResult; + }), + }, + gitManager: { + resolvePullRequest: () => + Effect.succeed({ + pullRequest: { + number: 1, + title: "A change", + url: "https://example.com/owner/repository/pull/1", + baseBranch: "main", + headBranch: "feature", + state: "open", + }, + }), + runStackedAction: () => + Effect.sync(() => { + calls.push("push"); + return actionResult; + }), + }, + vcsStatusBroadcaster: { + refreshStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + }, + }, + }); + for (const canWrite of [false, true]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: canWrite + ? `orchestration:read ${AuthSourceControlWriteScope}` + : "orchestration:read orchestration:operate", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const resolved = yield* client[WS_METHODS.gitResolvePullRequest]({ + cwd: cloneResult.cwd, + reference: "1", + }); + assert.equal(resolved.pullRequest.number, 1); + const clone = client[WS_METHODS.sourceControlCloneRepository]({ + remoteUrl: cloneResult.remoteUrl, + destinationPath: cloneResult.cwd, + }); + const push = client[WS_METHODS.gitRunStackedAction]({ + cwd: cloneResult.cwd, + actionId: "scoped-push", + action: "push", + }).pipe(Stream.runDrain); + if (canWrite) { + assert.deepEqual(yield* clone, cloneResult); + yield* push; + } else { + const comment = client[WS_METHODS.pullRequestsComment]({ + projectId: ProjectId.make("scoped-project"), + repository: "owner/repository", + number: 1, + body: "A comment", + }); + const errors = [ + yield* clone.pipe(Effect.flip), + yield* push.pipe(Effect.flip), + yield* comment.pipe(Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, AuthSourceControlWriteScope); + } + } + assert.deepEqual(calls, []); + } + }), + ), + ); + } + assert.deepEqual(calls, ["clone", "push"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each([ + { + name: "requires task permission before preparing a worktree for a thread", + scope: "source-control:write", + mode: "worktree", + withThread: true, + requiredScope: AuthOrchestrationOperateScope, + }, + { + name: "allows worktree setup with source-control and task permissions", + scope: "source-control:write orchestration:operate", + mode: "worktree", + withThread: true, + requiredScope: null, + }, + { + name: "keeps source-control permission required for worktree setup", + scope: "orchestration:operate", + mode: "worktree", + withThread: true, + requiredScope: AuthSourceControlWriteScope, + }, + { + name: "allows local checkout without task permission", + scope: "source-control:write", + mode: "local", + withThread: true, + requiredScope: null, + }, + { + name: "allows worktree preparation without a thread under source-control permission", + scope: "source-control:write", + mode: "worktree", + withThread: false, + requiredScope: null, + }, + ] as const)("pull request preparation $name", (testCase) => + Effect.gen(function* () { + let preparations = 0; + const result = { + pullRequest: { + number: 77, + title: "A change", + url: "https://example.com/owner/repository/pull/77", + baseBranch: "main", + headBranch: "feature", + state: "open" as const, + }, + branch: "feature", + worktreePath: testCase.mode === "worktree" ? "/workspace/pr-worktree" : null, + isOnPullRequestHead: true, + }; + yield* buildAppUnderTest({ + layers: { + gitManager: { + preparePullRequestThread: () => + Effect.sync(() => { + preparations += 1; + return result; + }), + }, + }, + }); + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: testCase.scope, + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const prepare = client[WS_METHODS.gitPreparePullRequestThread]({ + cwd: "/workspace", + reference: "77", + mode: testCase.mode, + ...(testCase.withThread ? { threadId: ThreadId.make("thread-pr-setup") } : {}), + }); + if (testCase.requiredScope === null) { + assert.deepEqual(yield* prepare, result); + assert.equal(preparations, 1); + } else { + const error = yield* prepare.pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, testCase.requiredScope); + } + assert.equal(preparations, 0); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => Effect.gen(function* () { let installStarts = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 79a4a9667c72..f432c2cc13a9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,6 +14,7 @@ import { AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, + AuthOrchestrationOperateScope, AuthSessionId, ClientConnectionMethod, ClientDeviceType, @@ -2507,6 +2508,12 @@ const makeWsRpcLayer = ( .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, + input.mode === "worktree" && input.threadId !== undefined + ? [ + requiredScopeForRpcMethod(WS_METHODS.gitPreparePullRequestThread), + AuthOrchestrationOperateScope, + ] + : undefined, ), [WS_METHODS.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 27bf2ede9b9a..8cbc7975ddab 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,14 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type ContextMenuItem, + type EnvironmentId, + type VcsRef, + type ThreadId, +} from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { @@ -28,6 +35,7 @@ import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches" import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; @@ -100,6 +108,8 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( @@ -142,6 +152,8 @@ export function BranchToolbarBranchSelector({ const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; + const canUpdateThreadBranch = !hasServerThread || canOperateThread; + const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -155,7 +167,12 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId || !activeProject) return; + if ( + !activeThreadId || + !activeProject || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) + return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ environmentId, @@ -264,8 +281,11 @@ export function BranchToolbarBranchSelector({ const isSelectingWorktreeBase = effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = - prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; - const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; + canChangeThreadBranch && prReference && onCheckoutPullRequestRequest + ? `__checkout_pull_request__:${prReference}` + : null; + const canCreateBranch = + canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; // The ref is created under its sanitized name, so the collision check has to // use that name too. Matching on the raw query would offer to create a ref // that already exists whenever sanitizing changes the name. @@ -383,6 +403,20 @@ export function BranchToolbarBranchSelector({ ); const runBranchAction = (action: () => Promise) => { + if ( + !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) { + // The menu already closed when the item was chosen; explain the no-op. + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Action unavailable", + description: "This connection cannot change the thread's branch.", + }), + ); + return; + } startBranchActionTransition(async () => { await action(); branchRefState.refresh(); @@ -391,7 +425,7 @@ export function BranchToolbarBranchSelector({ }; const selectBranch = (refName: VcsRef) => { - if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; + if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); @@ -452,6 +486,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { + if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; @@ -696,6 +731,17 @@ export function BranchToolbarBranchSelector({ index={index} value={itemValue} className="pe-1.5" + disabled={ + !canUpdateThreadBranch || + (!canWriteSourceControl && + !isSelectingWorktreeBase && + (!activeProjectCwd || + !resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + refName, + }).reuseExistingWorktree)) + } onClick={() => selectBranch(refName)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} > diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52d6bbace863..1e25fe73ba52 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, + AuthSourceControlWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -277,6 +278,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "~/state/session"; import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, @@ -421,11 +423,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { - readEnvironmentScope, - readPreparedConnection, - useEnvironmentScope, -} from "../state/session"; +import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; @@ -1407,6 +1405,7 @@ export default function ChatView(props: ChatViewProps) { const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -1794,7 +1793,7 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; - const canCheckoutPullRequestIntoThread = isLocalDraftThread; + const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ @@ -5382,6 +5381,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { + if (!canWriteSourceControl) return; if ( !activeProjectCwd || !activeThread || @@ -5437,6 +5437,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ + canWriteSourceControl, activeProjectCwd, activeThread, environmentId, @@ -5695,12 +5696,17 @@ export default function ChatView(props: ChatViewProps) { selectedProvider, ]); const handleRestoreThreadBranch = useCallback(() => { + if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); - }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); + }, [ + canWriteSourceControl, + gitStatusQuery.data?.hasWorkingTreeChanges, + handleSwitchCheckoutToThread, + ]); const composerBannerItems = useMemo(() => { const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; @@ -5748,7 +5754,7 @@ export default function ChatView(props: ChatViewProps) { - @@ -2032,6 +2076,7 @@ export default function GitActionsControl({ variant="outline" size="sm" onClick={continuePendingDefaultBranchAction} + disabled={!canWriteSourceControl} > {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} @@ -2039,6 +2084,7 @@ export default function GitActionsControl({ className="min-h-8 w-full max-w-full whitespace-normal py-1.5 leading-snug sm:min-h-7 sm:w-auto" size="sm" onClick={checkoutFeatureBranchAndContinuePendingAction} + disabled={!canChangeThreadBranch} > Checkout feature branch & continue diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts new file mode 100644 index 000000000000..103985580c81 --- /dev/null +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -0,0 +1,273 @@ +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canOperate: false, + canWriteSourceControl: true, + hookValues: [] as unknown[], + hookIndex: 0, + tracks: 0, + resets: 0, + manager: { operation: null as string | null, error: null, isRunning: false }, + interrupted: false, + pendingResponse: undefined as Promise | undefined, + openChanges: [] as boolean[], + checkouts: [] as string[], + setupScripts: 0, + prepared: [] as { branch: string; worktreePath: string | null }[], +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => { + const index = state.hookIndex++; + if (index === state.hookValues.length) state.hookValues.push(initial); + return [state.hookValues[index], (value: unknown) => (state.hookValues[index] = value)]; + }, + useRef: (current: unknown) => ({ current }), + useEffect: () => {}, +})); +vi.mock("@tanstack/react-pacer", () => ({ + useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }], +})); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => + scope === AuthOrchestrationOperateScope + ? state.canOperate + : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, + readEnvironmentScope: (_environmentId: unknown, scope: string) => + scope === AuthOrchestrationOperateScope + ? state.canOperate + : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, +})); +vi.mock("~/lib/sourceControlActions", async () => { + const { usePreparePullRequestThreadAction } = await vi.importActual< + typeof import("~/state/sourceControlActions") + >("~/state/sourceControlActions"); + return { + readCachedPullRequestResolution: () => null, + usePullRequestResolution: () => ({ + data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, + }), + usePreparePullRequestThreadAction, + }; +}); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.manager })); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} })); +vi.mock("~/state/git", () => ({ + gitEnvironment: { + preparePullRequestThread: async ({ + input: { mode, threadId }, + }: { + input: { mode: string; threadId?: string }; + }) => { + state.checkouts.push(mode); + if (mode === "worktree" && threadId) state.setupScripts += 1; + await state.pendingResponse; + if (state.interrupted) return AsyncResult.failure(Cause.interrupt()); + return AsyncResult.success({ + branch: "feature/pr", + worktreePath: mode === "worktree" ? "/worktree" : null, + }); + }, + }, +})); +vi.mock("~/lib/utils", () => ({ cn: () => "" })); +vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { status: () => null }, + vcsActionManager: { + stateAtom: () => "vcs-state", + track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => { + state.tracks += 1; + return execute(); + }, + resetError: () => state.resets++, + }, +})); +vi.mock("./ui/button", () => ({ Button: "Button" })); +vi.mock("./ui/input", () => ({ Input: "Input" })); +vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" })); +vi.mock("./ui/dialog", () => ({ + Dialog: "Dialog", + DialogDescription: "DialogDescription", + DialogFooter: "DialogFooter", + DialogHeader: "DialogHeader", + DialogPanel: "DialogPanel", + DialogPopup: "DialogPopup", + DialogTitle: "DialogTitle", +})); + +import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; + +function findAction(node: ReactNode, label: string): (() => unknown) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findAction(child, label); + if (action) return action; + } + return null; + } + if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null; + if (node.props.children === label) return node.props.onClick ?? null; + return findAction(node.props.children, label); +} + +function renderDialog() { + state.hookIndex = 0; + return PullRequestThreadDialog({ + open: true, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + cwd: "/repo", + initialReference: "123", + onOpenChange: (open) => state.openChanges.push(open), + onPrepared: (input) => { + state.prepared.push(input); + }, + }); +} + +function prepareAction(label: "Local" | "Worktree") { + const action = findAction(renderDialog(), label); + if (!action) throw new Error(`${label} action missing`); + return action; +} + +function visibleText(node: ReactNode): string { + if (Array.isArray(node)) return node.map(visibleText).join(" "); + if (typeof node === "string" || typeof node === "number") return String(node); + return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : ""; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +describe("pull request worktree permissions", () => { + beforeEach(() => { + state.canOperate = false; + state.canWriteSourceControl = true; + state.hookValues = []; + state.hookIndex = 0; + state.tracks = 0; + state.resets = 0; + state.manager = { operation: null, error: null, isRunning: false }; + state.interrupted = false; + state.pendingResponse = undefined; + state.openChanges = []; + state.checkouts = []; + state.setupScripts = 0; + state.prepared = []; + }); + + it("does not prepare a worktree or run setup with only source-control permission", async () => { + await prepareAction("Worktree")(); + + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([]); + }); + + it("prepares the worktree and thread when task permission is also granted", async () => { + state.canOperate = true; + await prepareAction("Worktree")(); + + expect(state.checkouts).toEqual(["worktree"]); + expect(state.setupScripts).toBe(1); + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]); + }); + + it("rechecks task permission before invoking a retained worktree action", async () => { + state.canOperate = true; + const prepare = prepareAction("Worktree"); + state.canOperate = false; + await prepare(); + + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + }); + + it("keeps local checkout available without task permission", async () => { + await prepareAction("Local")(); + + expect(state.checkouts).toEqual(["local"]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); + }); + + it.each(["Local", "Worktree"] as const)( + "shows the returned scope denial for a retained %s callback without starting a checkout", + async (label) => { + state.canOperate = true; + state.manager = { operation: "pull", error: null, isRunning: true }; + const prepare = prepareAction(label); + state.canWriteSourceControl = false; + + await prepare(); + + expect(visibleText(renderDialog())).toContain( + "This connection cannot change source control.", + ); + expect(state.checkouts).toEqual([]); + expect(state.setupScripts).toBe(0); + expect(state.prepared).toEqual([]); + expect(state.openChanges).toEqual([]); + expect(state.tracks).toBe(0); + expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true }); + }, + ); + + it("clears a returned error when a newly authorized retry starts, then completes normally", async () => { + const prepare = prepareAction("Local"); + state.canWriteSourceControl = false; + await prepare(); + expect(visibleText(renderDialog())).toContain("This connection cannot change source control."); + + state.canWriteSourceControl = true; + const response = deferred(); + state.pendingResponse = response.promise; + const completion = prepareAction("Local")(); + + expect(visibleText(renderDialog())).not.toContain( + "This connection cannot change source control.", + ); + expect(state.checkouts).toEqual(["local"]); + expect(state.prepared).toEqual([]); + response.resolve(undefined); + await completion; + + expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); + expect(state.openChanges).toEqual([false]); + }); + + it("keeps interrupted preparations quiet and preserves error reset behavior", async () => { + state.interrupted = true; + const before = visibleText(renderDialog()); + + await prepareAction("Local")(); + + expect(visibleText(renderDialog())).toBe(before); + expect(state.resets).toBe(1); + expect(state.prepared).toEqual([]); + expect(state.openChanges).toEqual([]); + }); +}); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c27..c08ac67335aa 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,5 +1,12 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,6 +19,7 @@ import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useEnvironmentQuery } from "~/state/query"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { @@ -49,6 +57,7 @@ export function PullRequestThreadDialog({ const [reference, setReference] = useState(initialReference ?? ""); const [referenceDirty, setReferenceDirty] = useState(false); const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); + const [prepareErrorMessage, setPrepareErrorMessage] = useState(null); const [debouncedReference, referenceDebouncer] = useDebouncedValue( reference, { wait: 450 }, @@ -102,6 +111,7 @@ export function PullRequestThreadDialog({ ); }, [parsedReference, sourceControlScope]); const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference @@ -131,6 +141,13 @@ export function PullRequestThreadDialog({ const handleConfirm = useCallback( async (mode: "local" | "worktree") => { + if (!preparePullRequestThreadAction.isAllowed) return; + if ( + mode === "worktree" && + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { + return; + } if (!parsedReference) { setReferenceDirty(true); return; @@ -138,6 +155,7 @@ export function PullRequestThreadDialog({ if (!parsedReference || !resolvedPullRequest || !cwd) { return; } + setPrepareErrorMessage(null); setPreparingMode(mode); const result = await preparePullRequestThreadAction.run({ reference: parsedReference, @@ -148,6 +166,13 @@ export function PullRequestThreadDialog({ if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { preparePullRequestThreadAction.resetError(); + } else { + const error = squashAtomCommandFailure(result); + setPrepareErrorMessage( + error instanceof Error + ? error.message + : `Failed to prepare ${terminology.singular} thread.`, + ); } return; } @@ -159,11 +184,13 @@ export function PullRequestThreadDialog({ }, [ cwd, + environmentId, onOpenChange, onPrepared, parsedReference, preparePullRequestThreadAction, resolvedPullRequest, + terminology.singular, threadId, ], ); @@ -177,6 +204,7 @@ export function PullRequestThreadDialog({ : null; const errorMessage = validationMessage ?? + prepareErrorMessage ?? (resolvedPullRequest === null && pullRequestResolution.error ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error @@ -270,10 +298,9 @@ export function PullRequestThreadDialog({ type="button" size="sm" variant="outline" - onClick={() => { - void handleConfirm("local"); - }} + onClick={() => handleConfirm("local")} disabled={ + !preparePullRequestThreadAction.isAllowed || !cwd || !resolvedPullRequest || isResolving || @@ -285,10 +312,10 @@ export function PullRequestThreadDialog({ diff --git a/apps/web/src/components/pullRequest/PullRequestReactions.tsx b/apps/web/src/components/pullRequest/PullRequestReactions.tsx index 2ef90b09a55c..bb02faaa4107 100644 --- a/apps/web/src/components/pullRequest/PullRequestReactions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReactions.tsx @@ -9,7 +9,7 @@ import { useState } from "react"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; @@ -66,7 +66,9 @@ export function PullRequestReactionBar({ readonly signature: string; readonly values: ReadonlyMap; }>({ signature: "", values: EMPTY_PENDING }); - const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false }); + const setReaction = useSourceControlCommand(pullRequestEnvironment.setReaction, { + reportFailure: false, + }); const signature = reactionsSignature(reactions); const values = pending.signature === signature ? pending.values : EMPTY_PENDING; diff --git a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx index f32352734685..6fd03dd387d1 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx @@ -9,7 +9,7 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; @@ -68,7 +68,7 @@ export function PullRequestReviewBar({ const removeComments = usePullRequestReviewStore((store) => store.removeComments); const setSummary = usePullRequestReviewStore((store) => store.setSummary); const clearSummary = usePullRequestReviewStore((store) => store.clearSummary); - const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, { + const submitReview = useSourceControlCommand(pullRequestEnvironment.submitReview, { reportFailure: false, }); diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index a4da0d99514d..63e42df5da5b 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -15,7 +15,7 @@ import { useMemo, useState } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { toastManager } from "../ui/toast"; @@ -56,7 +56,7 @@ export function PullRequestReviewerPicker({ const candidatesQuery = useEnvironmentQuery( open ? pullRequestEnvironment.reviewerCandidates({ environmentId, input: reference }) : null, ); - const requestReviewers = useAtomCommand(pullRequestEnvironment.requestReviewers, { + const requestReviewers = useSourceControlCommand(pullRequestEnvironment.requestReviewers, { reportFailure: false, }); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3bd04..018d0681e5f1 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -21,7 +21,7 @@ import { } from "lucide-react"; import { useRef, useState, type ReactNode } from "react"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { cn } from "~/lib/utils"; import { useOpenLink } from "~/browser/useOpenLink"; @@ -330,7 +330,9 @@ function CommentComposer({ }) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); - const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); + const postComment = useSourceControlCommand(pullRequestEnvironment.comment, { + reportFailure: false, + }); const followUpAction = detail.state === "open" && detail.capabilities.actions.includes("close") && @@ -534,8 +536,8 @@ export function PullRequestSummaryTab({ }); }; - const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); - const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { + const update = useSourceControlCommand(pullRequestEnvironment.update, { reportFailure: false }); + const updateComment = useSourceControlCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); // Keyed by the pull request, like the comment window above it, so an editor left open never diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 946f3cdbc54f..2efbf0754d32 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -22,7 +22,7 @@ import { useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { pullRequestEnvironment } from "~/state/pullRequests"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { useSourceControlCommand } from "~/state/use-source-control-command"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button } from "../ui/button"; @@ -194,7 +194,7 @@ function ConversationCard({ }) { const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); - const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { + const updateComment = useSourceControlCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index be9d63f17642..82241469446d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -22,6 +22,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthStandardClientScopes, AuthTerminalOperateScope, type AuthClientSession, @@ -220,6 +221,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Use terminals", description: "Create terminals and send input to running shells.", }, + { + scope: AuthSourceControlWriteScope, + title: "Change source control", + description: "Commit, push, manage branches and repositories, and change pull requests.", + }, { scope: AuthReviewWriteScope, title: "Write reviews", diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 1bdd04693759..b2bfc2d6c10e 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic"; const baseState: ThreadActionMenuState = { + canOperate: true, branch: null, isPinned: false, isSettled: false, @@ -27,6 +28,39 @@ function allIds(state: ThreadActionMenuState): string[] { } describe("buildThreadActionMenuItems", () => { + it.each([false, true])( + "disables both lifecycle directions without permission (reversed: %s)", + (reversed) => { + const items = buildThreadActionMenuItems({ + ...baseState, + canOperate: false, + isPinned: reversed, + isSettled: reversed, + isSnoozed: reversed, + }); + const expected = reversed + ? ["unpin", "unsettle", "unsnooze", "rename", "regenerate-title", "archive", "delete"] + : ["pin", "settle", "snooze", "rename", "regenerate-title", "archive", "delete"]; + expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual(expected); + expect( + items.find((item) => item.id === "snooze")?.children?.every((child) => child.disabled) ?? + true, + ).toBe(true); + }, + ); + + it("preserves local actions and restores mutations after a grant", () => { + const denied = buildThreadActionMenuItems({ ...baseState, canOperate: false, branch: "main" }); + expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ + "new-thread-on-branch", + "mark-unread", + "copy", + "project-settings", + ]); + const allowed = buildThreadActionMenuItems({ ...baseState, canOperate: true }); + expect(allowed.every((item) => !item.disabled)).toBe(true); + }); + it("hides lifecycle items when the environment lacks the capabilities", () => { expect( ids({ diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 5ba266f7709d..20f826c46dd3 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -27,6 +27,7 @@ export type ThreadActionMenuId = | "delete"; export interface ThreadActionMenuState { + readonly canOperate: boolean; readonly branch: string | null; readonly isPinned: boolean; readonly isSettled: boolean; @@ -44,6 +45,19 @@ export interface ThreadActionMenuState { readonly snoozePresets: ReadonlyArray; } +/** Local navigation, read markers, and copying remain available to read-only clients. */ +export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { + return ![ + "new-thread-on-branch", + "project-settings", + "mark-unread", + "copy", + "copy-path", + "copy-branch", + "copy-thread-id", + ].includes(action); +} + /** * Single source for the per-thread action menu: the sidebar row's right-click * menu and the chat header menu both render exactly this list, so labels, @@ -52,7 +66,7 @@ export interface ThreadActionMenuState { export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { - return [ + const items: ReadonlyArray> = [ ...(state.branch ? [ { @@ -140,4 +154,17 @@ export function buildThreadActionMenuItems( icon: "trash", }, ]; + return state.canOperate + ? items + : items.map((item) => + threadActionRequiresOperate(item.id) + ? { + ...item, + disabled: true, + ...(item.children + ? { children: item.children.map((child) => ({ ...child, disabled: true })) } + : {}), + } + : item, + ); } diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts new file mode 100644 index 000000000000..d12624c3d81c --- /dev/null +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -0,0 +1,218 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + ThreadId, + type ContextMenuItem, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ThreadActionMenuId } from "../components/threadActionMenu.logic"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const state = vi.hoisted(() => ({ + granted: new Set(), + effects: [] as string[], + completed: deferred(), + show: vi.fn< + ( + items: ReadonlyArray>, + position: { x: number; y: number }, + ) => Promise + >(), +})); + +function recordEffect(action: string) { + state.effects.push(action); + state.completed.resolve(); +} + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), +})); +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ navigate: async () => recordEffect("project-settings") }), +})); +vi.mock("../state/session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.granted.has(environmentId), +})); +vi.mock("../state/entities", () => ({ + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsSettlement: () => true, + readEnvironmentSupportsSnooze: () => true, + readEnvironmentSupportsTitleRegeneration: () => true, + readThreadShell: () => ({ + id: "thread", + environmentId: "secondary", + projectId: "project", + title: "Thread", + branch: "main", + worktreePath: null, + session: null, + latestTurn: null, + }), + useProjects: () => [{ id: "project", environmentId: "secondary" }], +})); +vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" })); +vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: () => async () => { + recordEffect("metadata"); + return AsyncResult.success(undefined); + }, +})); +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ + contextMenu: { show: state.show, close: () => {} }, + dialogs: { + confirm: async () => { + recordEffect("confirm"); + return false; + }, + }, + }), +})); +vi.mock("../logicalProject", () => ({ + deriveLogicalProjectKeyFromSettings: () => "project", + derivePhysicalProjectKey: () => "project", + selectProjectGroupingSettings: () => ({}), +})); +vi.mock("../sidebarProjectGrouping", () => ({ + buildPhysicalToLogicalProjectKeyMap: () => new Map(), +})); +vi.mock("../uiStateStore", () => ({ + useUiStateStore: (select: (store: unknown) => unknown) => + select({ + markThreadUnread: () => recordEffect("mark-unread"), + }), +})); +vi.mock("../components/ui/toast", () => ({ + stackedThreadToast: (toast: unknown) => toast, + toastManager: { add: () => state.completed.resolve() }, +})); +vi.mock("../components/Sidebar.snooze", () => ({ + resolveSnoozePresets: () => [ + { id: "hour", label: "In 1 hour", whenLabel: "3 PM", snoozedUntil: "2099-01-01T00:00:00Z" }, + ], + snoozeWakeDescription: () => "later", +})); +vi.mock("./useCopyToClipboard", () => ({ + useCopyToClipboard: () => ({ copyToClipboard: () => recordEffect("copy") }), +})); +vi.mock("./useHandleNewThread", () => ({ + useNewThreadHandler: () => async () => recordEffect("draft"), +})); +vi.mock("./useSettings", () => ({ + useClientSettings: (select: (settings: unknown) => unknown) => + select({ + confirmThreadDelete: true, + confirmThreadArchive: true, + timestampFormat: "12-hour", + }), +})); +vi.mock("./useThreadActions", () => ({ + useThreadActions: () => + Object.fromEntries( + [ + "settleThread", + "unsettleThread", + "snoozeThread", + "unsnoozeThread", + "pinThread", + "confirmAndUnpinThread", + "archiveThread", + "deleteThread", + ].map((action) => [ + action, + async () => { + recordEffect(action); + return AsyncResult.success(undefined); + }, + ]), + ), +})); + +import { useThreadActionMenu } from "./useThreadActionMenu"; + +const target = { + environmentId: EnvironmentId.make("secondary"), + threadId: ThreadId.make("thread"), +}; +const position = { x: 10, y: 20 }; +const createMenu = () => + useThreadActionMenu({ + threadRef: target, + projectCwd: "/project", + onStartRename: () => recordEffect("rename"), + }); + +beforeEach(() => { + state.granted = new Set(["primary"]); + state.effects = []; + state.completed = deferred(); + state.show.mockReset().mockResolvedValue(null); +}); + +describe("thread menu permissions", () => { + it("disables mutations for a denied secondary environment", () => { + createMenu().openMenu(position); + const items = state.show.mock.calls[0]![0]; + expect(items.find((item) => item.id === "rename")?.disabled).toBe(true); + expect(items.find((item) => item.id === "delete")?.disabled).toBe(true); + expect(items.find((item) => item.id === "copy")?.disabled).not.toBe(true); + }); + + it("allows the target grant even when the primary environment is denied", () => { + state.granted = new Set(["secondary"]); + createMenu().openMenu(position); + expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).not.toBe( + true, + ); + }); + + it("refreshes availability when a retained menu opener gains permission", () => { + const menu = createMenu(); + menu.openMenu(position); + expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).toBe(true); + state.granted.add("secondary"); + menu.openMenu(position); + expect(state.show.mock.calls[1]![0].find((item) => item.id === "rename")?.disabled).not.toBe( + true, + ); + }); + + it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( + "%s rechecks after the native menu closes", + async (action) => { + state.granted.add("secondary"); + const choice = deferred(); + state.show.mockReturnValue(choice.promise); + createMenu().openMenu(position); + state.granted.delete("secondary"); + choice.resolve(action); + await state.completed.promise; + expect(state.effects).toEqual([]); + }, + ); + + it.each([ + ["new-thread-on-branch", "draft"], + ["copy-thread-id", "copy"], + ["mark-unread", "mark-unread"], + ["project-settings", "project-settings"], + ] as const)("keeps %s available without task permission", async (action, effect) => { + state.show.mockResolvedValue(action); + createMenu().openMenu(position); + await state.completed.promise; + expect(state.effects).toEqual([effect]); + }); +}); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..61695c3eca37 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -6,18 +6,24 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + threadActionRequiresOperate, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; -import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; +import { readEnvironmentScope } from "../state/session"; import { readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, @@ -90,7 +96,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, } = useThreadActions(); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const handleNewThread = useNewThreadHandler(); @@ -138,6 +144,7 @@ export function useThreadActionMenu(input: { const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); const items = buildThreadActionMenuItems({ + canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", @@ -151,6 +158,16 @@ export function useThreadActionMenu(input: { const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; const action: ThreadActionMenuId = clicked.value; + if ( + threadActionRequiresOperate(action) && + !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { + failureToast( + "Thread action unavailable", + new Error("This connection cannot change threads."), + ); + return; + } if (action.startsWith("snooze:")) { const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); if (!preset) return; diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts new file mode 100644 index 000000000000..a22b8b7540df --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -0,0 +1,349 @@ +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, + ProjectId, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const state = vi.hoisted(() => ({ + scopes: new Map>(), + threads: [] as { + environmentId: EnvironmentId; + id: ThreadId; + projectId: ProjectId; + title: string; + worktreePath: string | null; + session: { status: "ready" | "stopped" } | null; + latestTurn: null; + }[], + requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[], + localEffects: [] as string[], + confirm: vi.fn<(message: string) => Promise>(), + afterRequest: undefined as ((action: string) => void) | undefined, + sessionLookupFails: false, +})); + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useRef: (current: unknown) => ({ current }), +})); +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }), +})); +vi.mock("../state/session", () => ({ + environmentSession: { sessionStateAtom: "session" }, + readEnvironmentScope: (environmentId: string, scope: string) => + state.scopes.get(environmentId)?.has(scope) === true, +})); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: + (action: string) => + async (request: { environmentId: string; input: { threadId?: string } }) => { + state.requests.push({ action, ...request }); + const scope = + action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; + if (!state.scopes.get(request.environmentId)?.has(scope)) { + return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); + } + state.afterRequest?.(action); + return AsyncResult.success(undefined); + }, +})); +vi.mock("../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => async (environmentId: string) => + state.sessionLookupFails + ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))) + : AsyncResult.success({ + authenticated: true, + scopes: [...(state.scopes.get(environmentId) ?? [])], + }), +})); +vi.mock("../state/threads", () => ({ + threadEnvironment: Object.fromEntries( + [ + "archive", + "unarchive", + "delete", + "settle", + "unsettle", + "pin", + "unpin", + "reorderPin", + "snooze", + "unsnooze", + "stopSession", + ].map((action) => [action, action]), + ), +})); +vi.mock("../state/vcs", () => ({ + vcsEnvironment: { removeWorktree: "removeWorktree", refreshStatus: "refreshStatus" }, +})); +vi.mock("../state/entities", () => ({ + readEnvironmentSupportsPinning: () => true, + readEnvironmentSupportsPinReorder: () => true, + readEnvironmentSupportsSettlement: () => true, + readEnvironmentSupportsSnooze: () => true, + readThreadShell: (ref: ScopedThreadRef) => + state.threads.find( + (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId, + ) ?? null, + readThreadShells: () => state.threads, + readEnvironmentThreadRefs: (environmentId: EnvironmentId) => + state.threads + .filter((thread) => thread.environmentId === environmentId) + .map((thread) => ({ environmentId, threadId: thread.id })), + readProject: () => ({ workspaceRoot: "/project" }), +})); +vi.mock("../components/Sidebar.logic", () => ({ + getFallbackThreadIdAfterDelete: () => null, + pinOrderKeyBetween: () => "a", +})); +vi.mock("../composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: unknown) => unknown) => + select({ + clearDraftThread: () => state.localEffects.push("clear-draft"), + clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"), + }), +})); +vi.mock("../terminalUiStateStore", () => ({ + useTerminalUiStateStore: (select: (store: unknown) => unknown) => + select({ + clearTerminalUiState: () => state.localEffects.push("clear-terminal-ui"), + }), +})); +vi.mock("../uiStateStore", () => ({ + useUiStateStore: (select: (store: unknown) => unknown) => + select({ + markThreadVisited: () => state.localEffects.push("mark-visited"), + }), +})); +vi.mock("../lib/archivedThreadsState", () => ({ + refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"), +})); +vi.mock("../lib/composerDraftUploads", () => ({ + releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"), +})); +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ dialogs: { confirm: state.confirm } }), +})); +vi.mock("../threadRoutes", () => ({ + resolveThreadRouteRef: () => null, + buildThreadRouteParams: (ref: ScopedThreadRef) => ref, +})); +vi.mock("../components/ui/toast", () => ({ + stackedThreadToast: (toast: unknown) => toast, + toastManager: { add: () => {} }, +})); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => async () => {} })); +vi.mock("./useSettings", () => ({ + useClientSettings: (select: (settings: unknown) => unknown) => + select({ + sidebarThreadSortOrder: "createdAt", + confirmThreadDelete: true, + confirmThreadUnpin: true, + }), +})); + +import { useThreadActions } from "./useThreadActions"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +const target = { environmentId: secondary, threadId: ThreadId.make("thread") }; +type ThreadActions = ReturnType; +const operations = [ + { + name: "archive", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.archiveThread(ref), + }, + { + name: "unarchive", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unarchiveThread(ref), + }, + { + name: "settle", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.settleThread(ref), + }, + { + name: "unsettle", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsettleThread(ref), + }, + { + name: "snooze", + run: (actions: ThreadActions, ref: ScopedThreadRef) => + actions.snoozeThread(ref, "2099-01-01T00:00:00Z"), + }, + { + name: "unsnooze", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref), + }, + { name: "pin", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.pinThread(ref) }, + { + name: "unpin", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unpinThread(ref), + }, + { + name: "reorderPin", + run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.reorderPinnedThread(ref, "b"), + }, +] as const; + +beforeEach(() => { + state.scopes = new Map([ + [primary, new Set([AuthOrchestrationOperateScope])], + [secondary, new Set()], + ]); + state.threads = [ + { + environmentId: secondary, + id: target.threadId, + projectId: ProjectId.make("project"), + title: "Thread", + worktreePath: null, + session: null, + latestTurn: null, + }, + ]; + state.requests = []; + state.localEffects = []; + state.confirm.mockReset().mockResolvedValue(true); + state.afterRequest = undefined; + state.sessionLookupFails = false; +}); + +describe("thread action permissions", () => { + it.each(operations)("$name requires the target environment's grant", async ({ run }) => { + const result = await run(useThreadActions(), target); + expect(result._tag).toBe("Failure"); + expect(state.requests).toEqual([]); + expect(state.localEffects).toEqual([]); + }); + + it.each(operations)("$name rechecks a retained callback after revocation", async ({ run }) => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const actions = useThreadActions(); + state.scopes.get(secondary)!.clear(); + await run(actions, target); + expect(state.requests).toEqual([]); + }); + + it.each(operations)( + "$name works after the target gains only task permission", + async ({ name, run }) => { + const actions = useThreadActions(); + state.scopes.get(primary)!.clear(); + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + expect((await run(actions, target))._tag).toBe("Success"); + expect(state.requests).toEqual([ + expect.objectContaining({ action: name, environmentId: secondary }), + ]); + }, + ); + + it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)( + "%s blocks a forbidden confirmation", + async (action) => { + await useThreadActions()[action](target); + expect(state.confirm).not.toHaveBeenCalled(); + expect(state.requests).toEqual([]); + }, + ); + + it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)( + "%s rechecks after the confirmation", + async (action) => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const confirmation = deferred(); + state.confirm.mockReturnValue(confirmation.promise); + const result = useThreadActions()[action](target); + expect(state.confirm).toHaveBeenCalledOnce(); + state.scopes.get(secondary)!.clear(); + confirmation.resolve(true); + await result; + expect(state.requests).toEqual([]); + expect(state.localEffects).toEqual([]); + }, + ); + + it("identifies the missing task scope when thread deletion is denied", async () => { + const result = await useThreadActions().deleteThread(target); + + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.requests).toEqual([]); + expect(state.localEffects).toEqual([]); + }); + + it("deletes an archived thread only with its own environment's grant", async () => { + state.threads = []; + const actions = useThreadActions(); + await actions.deleteThread(target); + expect(state.requests).toEqual([]); + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + expect((await actions.deleteThread(target))._tag).toBe("Success"); + expect(state.requests).toEqual([ + expect.objectContaining({ action: "delete", environmentId: secondary }), + ]); + }); + + it("stops before delete and local cleanup when permission is revoked during session stop", async () => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + state.threads[0]!.session = { status: "ready" }; + state.afterRequest = () => state.scopes.get(secondary)!.clear(); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure"); + expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]); + expect(state.localEffects).toEqual([]); + }); + + it.each([ + { reason: "without source-control permission", sessionLookupFails: false }, + { reason: "when the permission lookup fails", sessionLookupFails: true }, + ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + state.threads[0]!.worktreePath = "/worktrees/thread"; + state.sessionLookupFails = sessionLookupFails; + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(state.confirm).not.toHaveBeenCalled(); + expect(state.requests.map((request) => request.action)).toEqual(["delete"]); + expect(state.localEffects).toContain("clear-terminal-ui"); + }); + + it("does not request worktree removal after its grant is revoked during delete", async () => { + state.scopes + .get(secondary)! + .add(AuthOrchestrationOperateScope) + .add(AuthSourceControlWriteScope); + state.threads[0]!.worktreePath = "/worktrees/thread"; + state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await useThreadActions().deleteThread(target); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthSourceControlWriteScope }); + expect(state.requests.map((request) => request.action)).toEqual(["delete"]); + expect(state.localEffects).toContain("clear-terminal-ui"); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 64915228c779..3067a56048f4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -6,7 +6,14 @@ import { } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -15,7 +22,7 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; -import { terminalEnvironment } from "../state/terminal"; +import { environmentSession, readEnvironmentScope } from "../state/session"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -39,6 +46,8 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -144,42 +153,57 @@ export async function requestThreadUnpinConfirmation(input: { ); } +function threadOperationFailure(target: ScopedThreadRef) { + return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) + ? null + : AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection cannot change threads.", + requiredScope: AuthOrchestrationOperateScope, + }), + ), + ); +} + export function useThreadActions() { - const closeTerminal = useAtomCommand(terminalEnvironment.close); - const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { + const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, { reportFailure: false, }); - const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, { reportFailure: false, }); - const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { + const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false, }); - const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { reportFailure: false, }); - const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { reportFailure: false, }); - const pinThreadMutation = useAtomCommand(threadEnvironment.pin, { + const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { reportFailure: false, }); - const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { + const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { reportFailure: false, }); - const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { + const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { reportFailure: false, }); - const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { + const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { reportFailure: false, }); - const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { reportFailure: false, }); - const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); + const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); + const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { + reportFailure: false, + }); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false, }); @@ -281,6 +305,8 @@ export function useThreadActions() { const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + const permissionFailure = threadOperationFailure(target); + if (permissionFailure) return permissionFailure; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. @@ -322,8 +348,20 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); + let canDeleteWorktree = false; + if (orphanedWorktreePath !== null && threadProject !== null && localApi) { + // The session lookup only decides whether to offer worktree cleanup. + // A failed lookup is treated like a missing grant: delete the thread + // and leave the worktree behind rather than refusing the delete. + const sessionResult = await loadSessionState(threadRef.environmentId); + const permissionFailure = threadOperationFailure(threadRef); + if (permissionFailure) return permissionFailure; + canDeleteWorktree = + sessionResult._tag === "Success" && + sessionResult.value.authenticated && + sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; + } let shouldDeleteWorktree = false; if (canDeleteWorktree && localApi) { const confirmationResult = await settlePromise(() => @@ -350,11 +388,6 @@ export function useThreadActions() { }); } - await closeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, deleteHistory: true }, - }); - const deletedThreadIds = deletedIds ?? new Set(); const currentRouteThreadRef = getCurrentRouteThreadRef(); const shouldNavigateToFallback = @@ -422,14 +455,26 @@ export function useThreadActions() { return deleteResult; } - const removeResult = await removeWorktree({ - environmentId: threadRef.environmentId, - input: { - cwd: threadProject.workspaceRoot, - path: orphanedWorktreePath, - force: true, - }, - }); + const removeResult = readEnvironmentScope( + threadRef.environmentId, + AuthSourceControlWriteScope, + ) + ? await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, + path: orphanedWorktreePath, + force: true, + }, + }) + : AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection can no longer remove worktrees.", + requiredScope: AuthSourceControlWriteScope, + }), + ), + ); const refreshResult = removeResult._tag === "Success" ? await refreshVcsStatus({ @@ -467,9 +512,9 @@ export function useThreadActions() { clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, - closeTerminal, deleteThreadMutation, getCurrentRouteThreadRef, + loadSessionState, refreshVcsStatus, removeWorktree, router, @@ -588,6 +633,8 @@ export function useThreadActions() { const confirmAndUnpinThread = useCallback( async (target: ScopedThreadRef) => { + const permissionFailure = threadOperationFailure(target); + if (permissionFailure) return permissionFailure; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); const confirmationResult = await requestThreadUnpinConfirmation({ @@ -686,6 +733,8 @@ export function useThreadActions() { const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { + const permissionFailure = threadOperationFailure(target); + if (permissionFailure) return permissionFailure; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); diff --git a/apps/web/src/state/sourceControlActions.test.ts b/apps/web/src/state/sourceControlActions.test.ts new file mode 100644 index 000000000000..d679ddc4bcc5 --- /dev/null +++ b/apps/web/src/state/sourceControlActions.test.ts @@ -0,0 +1,209 @@ +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Map>(), + requests: [] as { action: string; environmentId: string | null }[], +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ operation: null, error: null, isRunning: false }), +})); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("./session", () => ({ + useEnvironmentScope: (environmentId: string, scope: string) => + state.scopes.get(environmentId)?.has(scope) === true, + readEnvironmentScope: (environmentId: string, scope: string) => + state.scopes.get(environmentId)?.has(scope) === true, +})); +vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ refresh: () => {} }) })); +vi.mock("./use-atom-command", () => ({ + useAtomCommand: + (command: { action: string; environmentId?: string }) => + async (input: { environmentId?: string }) => { + state.requests.push({ + action: command.action, + environmentId: input.environmentId ?? command.environmentId ?? null, + }); + return AsyncResult.success({}); + }, +})); +vi.mock("./vcs", () => ({ + vcsEnvironment: { init: { action: "init" }, pull: { action: "pull" }, status: () => "status" }, + vcsActionManager: { + stateAtom: () => "state", + resetError: () => {}, + track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => + execute(), + runStackedAction: (scope: { environmentId: string }) => ({ action: "stack", ...scope }), + }, +})); +vi.mock("./sourceControl", () => ({ + sourceControlEnvironment: { publishRepository: { action: "publish" } }, +})); +vi.mock("./git", () => ({ + gitEnvironment: { preparePullRequestThread: { action: "prepare" } }, +})); + +import { + useGitStackedAction, + usePreparePullRequestThreadAction, + useSourceControlPublishRepositoryAction, + useVcsInitAction, + useVcsPullAction, +} from "./sourceControlActions"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +const scope = { environmentId: secondary, cwd: "/repo" }; +const threadId = ThreadId.make("thread"); +const cases = [ + { + name: "init", + create: () => { + const action = useVcsInitAction(scope); + return { isAllowed: action.isAllowed, run: () => action.run() }; + }, + }, + { + name: "pull", + create: () => { + const action = useVcsPullAction(scope); + return { isAllowed: action.isAllowed, run: () => action.run() }; + }, + }, + { + name: "stack", + create: () => { + const action = useGitStackedAction(scope); + return { + isAllowed: action.isAllowed, + run: () => action.run({ actionId: "action", action: "commit" }), + }; + }, + }, + { + name: "publish", + create: () => { + const action = useSourceControlPublishRepositoryAction(scope); + return { + isAllowed: action.isAllowed, + run: () => + action.run({ + provider: "github", + repository: "owner/repo", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }), + }; + }, + }, + { + name: "prepare", + create: () => { + const action = usePreparePullRequestThreadAction(scope); + return { + isAllowed: action.isAllowed, + run: () => action.run({ reference: "1", mode: "local" }), + }; + }, + }, +] as const; + +beforeEach(() => { + state.scopes = new Map([ + [primary, new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])], + [secondary, new Set()], + ]); + state.requests = []; +}); + +describe("source control callback grants", () => { + it.each(cases)("$name uses the selected environment's permission", async ({ create }) => { + const action = create(); + expect(action.isAllowed).toBe(false); + expect((await action.run())._tag).toBe("Failure"); + expect(state.requests).toEqual([]); + }); + + it.each(cases)("$name rejects a retained callback after revocation", async ({ create }) => { + state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); + const action = create(); + expect(action.isAllowed).toBe(true); + state.scopes.get(secondary)!.clear(); + expect((await action.run())._tag).toBe("Failure"); + expect(state.requests).toEqual([]); + expect(create().isAllowed).toBe(false); + }); + + it.each(cases)("$name accepts a retained callback after a grant", async ({ name, create }) => { + const action = create(); + expect(action.isAllowed).toBe(false); + state.scopes.get(primary)!.clear(); + state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); + expect((await action.run())._tag).toBe("Success"); + expect(state.requests).toEqual([{ action: name, environmentId: secondary }]); + expect(create().isAllowed).toBe(true); + }); +}); + +describe("pull request worktree attachment permission", () => { + it.each([ + ["local", threadId, false, true], + ["worktree", undefined, false, true], + ["worktree", threadId, false, false], + ["worktree", threadId, true, true], + ] as const)( + "mode %s / thread %s / task grant %s", + async (mode, attachedThreadId, operate, allowed) => { + state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); + if (operate) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const result = await usePreparePullRequestThreadAction(scope).run({ + reference: "1", + mode, + ...(attachedThreadId !== undefined ? { threadId: attachedThreadId } : {}), + }); + expect(result._tag).toBe(allowed ? "Success" : "Failure"); + expect(state.requests).toHaveLength(allowed ? 1 : 0); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toMatchObject({ + requiredScope: AuthOrchestrationOperateScope, + }); + } + }, + ); + + it.each([false, true])( + "reads the fresh task grant before attaching a worktree: %s", + async (allowed) => { + state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); + if (!allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const action = usePreparePullRequestThreadAction(scope); + if (allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + else state.scopes.get(secondary)!.delete(AuthOrchestrationOperateScope); + const result = await action.run({ reference: "1", mode: "worktree", threadId }); + expect(result._tag).toBe(allowed ? "Success" : "Failure"); + expect(state.requests).toHaveLength(allowed ? 1 : 0); + }, + ); + + it("still requires source-control permission when the task grant is present", async () => { + state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); + const result = await usePreparePullRequestThreadAction(scope).run({ + reference: "1", + mode: "worktree", + threadId, + }); + expect(result._tag).toBe("Failure"); + expect(state.requests).toEqual([]); + }); +}); diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df1..88163f1723da 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -8,14 +8,17 @@ import { VcsActionUnavailableError, type VcsActionOperation, } from "@t3tools/client-runtime/state/vcs"; -import type { - EnvironmentId, - GitActionProgressEvent, - GitResolvePullRequestResult, - GitStackedAction, - SourceControlCloneProtocol, - SourceControlRepositoryVisibility, - ThreadId, +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + type EnvironmentId, + type GitActionProgressEvent, + type GitResolvePullRequestResult, + type GitStackedAction, + type SourceControlCloneProtocol, + type SourceControlRepositoryVisibility, + type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -25,6 +28,7 @@ import { useCallback } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { gitEnvironment } from "./git"; import { useEnvironmentQuery } from "./query"; +import { readEnvironmentScope, useEnvironmentScope } from "./session"; import { sourceControlEnvironment } from "./sourceControl"; import { useAtomCommand } from "./use-atom-command"; import { vcsActionManager, vcsEnvironment } from "./vcs"; @@ -46,11 +50,15 @@ interface SourceControlActionState< R extends AtomCommandResult, > { readonly isPending: boolean; + readonly isAllowed: boolean; readonly error: unknown; readonly run: ( ...args: TArgs ) => Promise< - AtomCommandResult, AtomCommandFailure | VcsActionUnavailableError> + AtomCommandResult< + AtomCommandSuccess, + AtomCommandFailure | VcsActionUnavailableError | EnvironmentAuthorizationError + > >; readonly resetError: () => void; } @@ -74,6 +82,7 @@ function useAction< readonly onSuccess?: () => void; readonly managedExternally?: boolean; }): SourceControlActionState { + const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope); const operation = ACTION_OPERATION[input.kind]; const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); const ownsState = state.operation === operation; @@ -84,6 +93,19 @@ function useAction< const run = useCallback( async (...args: TArgs) => { + if ( + input.scope.environmentId === null || + !readEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope) + ) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSourceControlWriteScope, + message: "This connection cannot change source control.", + }), + ), + ); + } const execute = async (): Promise< AtomCommandResult, AtomCommandFailure> > => { @@ -109,6 +131,7 @@ function useAction< ); return { + isAllowed, error: ownsState ? state.error : null, isPending: ownsState && state.isRunning, resetError, @@ -322,6 +345,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop ), ); } + if ( + input.mode === "worktree" && + input.threadId !== undefined && + !readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) + ) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationOperateScope, + message: "This connection cannot change threads.", + }), + ), + ); + } return preparePullRequestThread({ environmentId: target.environmentId, input: { diff --git a/apps/web/src/state/use-source-control-command.test.ts b/apps/web/src/state/use-source-control-command.test.ts new file mode 100644 index 000000000000..603ad072c7a0 --- /dev/null +++ b/apps/web/src/state/use-source-control-command.test.ts @@ -0,0 +1,131 @@ +import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const state = vi.hoisted(() => ({ + grants: new Map>(), + run: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + state.grants.get(environmentId)?.has(scope) === true, +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); + +import { useSourceControlCommand } from "./use-source-control-command"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +type Target = { environmentId: EnvironmentId; input: { action: string } }; +const command: AtomCommand = { + label: "pull request mutation", + run: state.run, +}; +const target = (environmentId: EnvironmentId, action = "comment"): Target => ({ + environmentId, + input: { action }, +}); + +beforeEach(() => { + state.grants.clear(); + state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); +}); + +it("does not borrow the primary environment's grant for a secondary pull request", async () => { + state.grants.set(primary, new Set([AuthSourceControlWriteScope])); + const mutate = useSourceControlCommand(command, { reportFailure: false }); + + const result = await mutate(target(secondary)); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(EnvironmentAuthorizationError); + expect(Cause.squash(result.cause)).toMatchObject({ + requiredScope: AuthSourceControlWriteScope, + }); + } + expect(state.run).not.toHaveBeenCalled(); +}); + +it("allows source control on the target without task or primary-environment permission", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + + const result = await useSourceControlCommand(command)(target(secondary)); + + expect(result).toMatchObject({ _tag: "Success", value: "receipt" }); + expect(state.run).toHaveBeenCalledWith(target(secondary)); +}); + +it("does not accept task permission in place of source-control permission", async () => { + state.grants.set(secondary, new Set([AuthOrchestrationOperateScope])); + + expect((await useSourceControlCommand(command)(target(secondary)))._tag).toBe("Failure"); + expect(state.run).not.toHaveBeenCalled(); +}); + +it("blocks a retained menu callback after revocation", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const mutate = useSourceControlCommand(command); + state.grants.delete(secondary); + + expect((await mutate(target(secondary, "set-labels")))._tag).toBe("Failure"); + expect(state.run).not.toHaveBeenCalled(); +}); + +it("allows a retained callback after the target gains its grant", async () => { + const mutate = useSourceControlCommand(command); + expect((await mutate(target(secondary, "submit-review")))._tag).toBe("Failure"); + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + + expect((await mutate(target(secondary, "submit-review")))._tag).toBe("Success"); + expect(state.run).toHaveBeenCalledTimes(1); +}); + +it.each(["close", "reopen"])( + "keeps an accepted comment but does not dispatch %s after access changes during posting", + async (action) => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const commentReceipt = AsyncResult.success("comment receipt"); + const posted = deferred(); + state.run.mockReturnValueOnce(posted.promise); + const mutate = useSourceControlCommand(command); + const commentThenAction = async () => { + const comment = await mutate(target(secondary, "comment")); + if (comment._tag === "Failure") return { commentPosted: false, action }; + const result = await mutate(target(secondary, action)); + return { commentPosted: true, action: result._tag }; + }; + + const pending = commentThenAction(); + state.grants.delete(secondary); + posted.resolve(commentReceipt); + + expect(await pending).toEqual({ commentPosted: true, action: "Failure" }); + expect(state.run).toHaveBeenCalledExactlyOnceWith(target(secondary, "comment")); + }, +); + +it("preserves an allowed host failure for the caller's existing error handling", async () => { + state.grants.set(secondary, new Set([AuthSourceControlWriteScope])); + const failure = AsyncResult.failure(Cause.fail(new Error("Branch protection refused the merge"))); + state.run.mockResolvedValue(failure); + + expect(await useSourceControlCommand(command)(target(secondary, "merge"))).toBe(failure); +}); diff --git a/apps/web/src/state/use-source-control-command.ts b/apps/web/src/state/use-source-control-command.ts new file mode 100644 index 000000000000..3ecc928e21b6 --- /dev/null +++ b/apps/web/src/state/use-source-control-command.ts @@ -0,0 +1,40 @@ +import type { + AtomCommand, + AtomCommandOptions, + AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthSourceControlWriteScope, + EnvironmentAuthorizationError, + type EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { readEnvironmentScope } from "./session"; +import { useAtomCommand } from "./use-atom-command"; + +/** Recheck the target grant for each mutation, including steps after a host response. */ +export function useSourceControlCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const run = useAtomCommand(command, options); + return useCallback( + async (value: W): Promise> => { + if (!readEnvironmentScope(value.environmentId, AuthSourceControlWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSourceControlWriteScope, + message: "This connection cannot change source control.", + }), + ), + ); + } + return run(value); + }, + [run], + ); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 8de5ca89a139..19386ecadce3 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -158,6 +158,12 @@ Grouping checkouts does not combine their permissions. Shared project settings require `orchestration:operate` on every member environment; actions on one checkout use that checkout's permissions. +`source-control:write` covers direct Git and pull request changes made from the +client: pushing, switching or creating branches, cloning, and removing +worktrees. It does not restrict what a task does. Starting a task in a new +worktree still creates that branch and worktree with `orchestration:operate`, +and the agent it runs can use Git however the environment allows. + Settings changes, provider management, and environment maintenance can be granted separately from access administration. New standard pairings include these permissions. Existing clients keep their original grants after an update; to diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 4d0e2eece11c..8ed9655bc56b 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; 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 AuthReviewWriteScope = "review:write" as const; export const AuthAccessReadScope = "access:read" as const; export const AuthAccessWriteScope = "access:write" as const; @@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthAccessReadScope, AuthAccessWriteScope, AuthRelayReadScope, @@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [ AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, + AuthSourceControlWriteScope, AuthRelayReadScope, ] as const; export const AuthAdministrativeScopes = [