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..465bd8d71f8b --- /dev/null +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -0,0 +1,173 @@ +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(), + baseDirectory: "", + projects: [] as 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: unknown, scope: string) => state.scopes.has(scope), + readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), +})); +vi.mock("../../state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map([ + [ + "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" }], + }), + useSavedRemoteConnections: () => ({ + savedConnectionsById: { + connection: { environmentId: "environment", environmentLabel: "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 ({ input }: { input: { workspaceRoot: string } }) => { + if (!state.scopes.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); + } + state.projects.push(input.workspaceRoot); + return AsyncResult.success(undefined); + }, + }, +})); + +import { AddProjectDestinationScreen } from "./AddProjectScreen"; + +function findCloneAction(node: ReactNode): (() => unknown) | null { + if (Array.isArray(node)) { + for (const child of node) { + const action = findCloneAction(child); + if (action) return action; + } + return null; + } + if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { + return null; + } + if (node.props.label === "Clone project") return node.props.onPress ?? null; + return findCloneAction(node.props.children); +} + +function cloneAction() { + const action = findCloneAction( + AddProjectDestinationScreen({ + environmentId: "environment", + remoteUrl: "https://example.com/repo.git", + repositoryName: "repo", + }), + ); + if (!action) throw new Error("Clone 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.projects = []; + }); + + 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([]); + }, + ); +}); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index cc1e8f4e5799..015dfd24b478 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 @@ -554,11 +566,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} /> @@ -908,6 +922,15 @@ export function AddProjectDestinationScreen(props: { reportFailure: false, }); const environment = useEnvironmentFromParam(props.environmentId); + const canWriteSourceControl = useEnvironmentScope( + environment?.environmentId ?? null, + AuthSourceControlWriteScope, + ); + const canCreateProject = useEnvironmentScope( + environment?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const canCloneProject = canWriteSourceControl && canCreateProject; const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); @@ -924,7 +947,16 @@ export function AddProjectDestinationScreen(props: { const [error, setError] = useState(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 +1008,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.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index c66fc7887624..2d1757cee63c 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,8 +88,9 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { icon="plus" label="Create & checkout" tone="primary" - disabled={busy || newBranchName.trim().length === 0} + disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} onPress={() => { + if (!canChangeThreadBranch) return; const branch = sanitizeFeatureBranchName(newBranchName.trim()); if (branch.length === 0) return; void gitActions.onCreateSelectedThreadBranch(branch).then(() => { @@ -115,11 +122,13 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { label="Create worktree" tone="primary" disabled={ + !canChangeThreadBranch || busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 } onPress={() => { + if (!canChangeThreadBranch) return; const baseBranch = worktreeBaseBranch.trim(); const newBranch = worktreeBranchName.trim(); if (baseBranch.length === 0 || newBranch.length === 0) return; @@ -162,8 +171,9 @@ 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} + disabled={!canChangeThreadBranch || busy || disabled} onPress={() => { + if (!canChangeThreadBranch) return; void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { 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..96cf77818bec 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 } = 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..6f7241d3aee6 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -0,0 +1,160 @@ +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(), + branch: "main", + worktrees: [] as string[], + thread: { + id: "thread", + environmentId: "environment", + branch: "main", + worktreePath: null as string | null, + }, + commits: 0, + pushes: 0, +})); + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useEffect: () => {}, +})); +vi.mock("./session", () => ({ + useEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.has(scope), + readEnvironmentScope: (_environmentId: unknown, scope: string) => state.scopes.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: () => {} })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" })); +vi.mock("./threads", () => ({ + threadEnvironment: { + updateMetadata: async ({ + input, + }: { + input: { branch: string; worktreePath: string | null }; + }) => { + 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 () => AsyncResult.success({ refName: state.branch }), + switchRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + return AsyncResult.success({ refName: state.branch }); + }, + createRef: async ({ input }: { input: { refName: string } }) => { + state.branch = input.refName; + return AsyncResult.success({ refName: state.branch }); + }, + createWorktree: async ({ input }: { input: { newRefName: string } }) => { + state.worktrees.push("/repo-worktree"); + 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; + 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.branch = "main"; + state.worktrees = []; + state.thread.branch = "main"; + state.thread.worktreePath = null; + state.commits = 0; + state.pushes = 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(); + await actions.onCreateSelectedThreadWorktree({ + baseBranch: "main", + newBranch: "feature/task", + }); + 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") await actions.onCheckoutSelectedThreadBranch("feature"); + 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); + await actions.onCreateSelectedThreadWorktree({ baseBranch: "main", newBranch: "feature/task" }); + 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"); + }); +}); 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..83978dcecd7b 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,11 @@ import { type VcsActionOperation, type VcsRef, } from "@t3tools/client-runtime/state/vcs"; -import type { GitRunStackedActionResult } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, @@ -20,6 +24,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 +41,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({ @@ -131,9 +145,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; } @@ -216,6 +237,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -228,7 +250,7 @@ export function useSelectedThreadGitActions() { const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation( + return runSelectedThreadGitMutation( "create_ref", "Creating branch", async ({ thread, cwd }) => { @@ -249,6 +271,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [ @@ -287,6 +310,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + { changesThreadBranch: true }, ); }, [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], @@ -360,7 +384,7 @@ export function useSelectedThreadGitActions() { } return result; }, - { managedExternally: true }, + { managedExternally: true, changesThreadBranch: input.featureBranch === true }, ); }, [ @@ -373,6 +397,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 d2e3b6b5d68c..f4dfbacfdc0c 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, @@ -109,16 +110,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 773991d6c51a..d841f84265f1 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, @@ -314,6 +315,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 65a078da42c8..df00fcc51613 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, @@ -5571,6 +5573,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 aaa607bc044b..e163b82b4f1c 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, @@ -2460,6 +2461,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..73552e82f414 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,11 @@ export function BranchToolbarBranchSelector({ ); const runBranchAction = (action: () => Promise) => { + if ( + !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || + (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + ) + return; startBranchActionTransition(async () => { await action(); branchRefState.refresh(); @@ -391,7 +416,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 +477,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { + if (!canChangeThreadBranch) return; const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; @@ -696,6 +722,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 9b80b5a976cb..db315ba327b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { AuthSettingsWriteScope, + AuthSourceControlWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -272,6 +273,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, @@ -1395,6 +1397,7 @@ export default function ChatView(props: ChatViewProps) { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, @@ -1780,7 +1783,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({ @@ -5315,6 +5318,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { + if (!canWriteSourceControl) return; if ( !activeProjectCwd || !activeThread || @@ -5370,6 +5374,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ + canWriteSourceControl, activeProjectCwd, activeThread, environmentId, @@ -5622,12 +5627,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]; @@ -5675,7 +5685,7 @@ export default function ChatView(props: ChatViewProps) { - @@ -2032,6 +2068,7 @@ export default function GitActionsControl({ variant="outline" size="sm" onClick={continuePendingDefaultBranchAction} + disabled={!canWriteSourceControl} > {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} @@ -2039,6 +2076,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..5736efd61ca5 --- /dev/null +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -0,0 +1,140 @@ +import { AuthOrchestrationOperateScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isValidElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canOperate: false, + 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) => [initial, () => {}], + 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, + readEnvironmentScope: (_environmentId: unknown, scope: string) => + scope === AuthOrchestrationOperateScope && state.canOperate, +})); +vi.mock("~/lib/sourceControlActions", () => ({ + readCachedPullRequestResolution: () => null, + usePullRequestResolution: () => ({ + data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, + }), + usePreparePullRequestThreadAction: () => ({ + isAllowed: true, + isPending: false, + error: null, + run: async ({ mode, threadId }: { mode: string; threadId?: string }) => { + state.checkouts.push(mode); + if (mode === "worktree" && threadId) state.setupScripts += 1; + return { + _tag: "Success", + value: { 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 } })); +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 prepareAction(label: "Local" | "Worktree") { + const action = findAction( + PullRequestThreadDialog({ + open: true, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + cwd: "/repo", + initialReference: "123", + onOpenChange: () => {}, + onPrepared: (input) => { + state.prepared.push(input); + }, + }), + label, + ); + if (!action) throw new Error(`${label} action missing`); + return action; +} + +describe("pull request worktree permissions", () => { + beforeEach(() => { + state.canOperate = false; + 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 }]); + }); +}); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c27..c696c32639d5 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,8 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,6 +16,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 { @@ -102,6 +107,7 @@ export function PullRequestThreadDialog({ ); }, [parsedReference, sourceControlScope]); const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference @@ -131,6 +137,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; @@ -159,6 +172,7 @@ export function PullRequestThreadDialog({ }, [ cwd, + environmentId, onOpenChange, onPrepared, parsedReference, @@ -270,10 +284,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 +298,10 @@ export function PullRequestThreadDialog({